joeygambino commited on
Commit
20a8ff6
·
verified ·
1 Parent(s): f00316b

v1.6: voice casting system + sampler-path audit fixes

Browse files
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.5_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,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
- for p in sorted(jroot.glob("*.json")):
64
- out.append(_JSON_PREFIX + p.name)
 
 
 
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
- print(f"[JoyEcho] PromptSource: {p.name} (json, {len(arr)} shots, 1 item).", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- print(f"[JoyEcho] ScriptPicker: {script} ({len(arr)} shots).", flush=True)
85
- return (text, str(p),)
 
 
 
 
 
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
- self.memory = fixed + (tail[-keep_tail:] if keep_tail > 0 else [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- audio_latents = [entry.audio_latent for entry in self.memory]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return torch.cat(audio_latents, dim=1).contiguous()
 
 
 
 
 
 
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