Streaming / real-time generation¶
The offline pipeline solves a whole clip at once. A live pipeline — a TTS engine
emitting phonemes as it speaks — needs to emit animation incrementally, with
memory that stays constant no matter how long the stream runs.
StreamingGenerator (issue #43) does
that: push(chunk_of_segments) returns the keyframe frames that just became
final, flush() (alias close()) emits the tail. It reuses the exact offline
component math — coarticulation._blend, the shared core of
build_viseme_curves — over a bounded segment window.
from openfacefx import StreamingGenerator, frames_to_track
gen = StreamingGenerator(fps=60.0, look_ahead=0.5) # look_ahead = latency dial
frames = []
for chunk in phoneme_segment_chunks: # e.g. from a live aligner
frames += gen.push(chunk)
frames += gen.flush()
track = frames_to_track(frames, 60.0) # -> a normal FaceTrack
Honesty: reproduces the offline solve within tolerance, not bit-exactly¶
This is important and stated plainly. The coarticulation dominance is a Laplacian
bump D_i(t) = alpha·exp(-theta·|t − c_i|) — exponential, infinite support —
and the blend normalizes over every segment. Bounded memory (pruning old
segments) and a finite look-ahead (dropping far-future ones) therefore both omit
exponentially small tails, so no finite window is bit-identical to
generate_from_alignment. That is fundamental to this dominance model, not an
implementation gap.
It converges fast, though. The per-frame error from a window W seconds wide is
bounded by O(exp(-theta·W)) (slowest θ ≈ 2.9/s for a long vowel):
look-ahead W |
~ max per-frame error | ≈ |
|---|---|---|
| 1.5 s | 1e-2 | the RDP epsilon (0.015) |
| 3 s | 1e-4 | the 4-dp keyframe grid |
| 4.5 s | 1e-6 | 10⁴× below perceptual / storage precision |
look_ahead is the single latency ↔ fidelity dial: 0 = zero latency, no
anticipatory coarticulation (causal only); larger = more anticipation, tighter to
offline. There is one exact case: when the window covers the whole clip
(look_ahead and back_span ≥ clip duration) the per-frame blend is
bit-identical to offline, and frames_to_track then reduces to the same
keyframes as generate_from_alignment.
Guarantees¶
- Chunk boundaries never matter. The same clip pushed in 1 or K chunks yields bit-identical frames — windows are selected by frame time, not by arrival.
- Bounded memory. The cooked-segment ring buffer stays
O(look_ahead + back_span), independent of stream length. - Causal. A frame, once emitted, is never revised; its value depends only on
inputs within its window, so a later chunk cannot alter an already-emitted
keyframe (the flip side of the tolerance). The optional
causal_smoothis a past-only one-pole filter, deliberately distinct from the offline symmetricpostprocess.smooth_matrix(which reads both directions and so is not reproducible causally). - Not offline RDP. Frames are emitted incrementally (dense), so the generator
does not reproduce offline's global Ramer–Douglas–Peucker keyframe reduction
(that needs the whole column = O(stream));
frames_to_trackapplies the same RDP once at the end for storage.
Network transport (gRPC / WebSocket) is out of scope — this is an in-process generator only. Deterministic; numpy + stdlib.
openfacefx.streaming
¶
Streaming / real-time generation (issue #43): a long-lived coarticulation generator that carries state across pushed chunks in bounded memory.
The offline pipeline solves a whole clip at once. A live pipeline (a TTS engine
emitting phonemes as it speaks) needs to emit animation incrementally, with
constant memory regardless of stream length. :class:StreamingGenerator does
that: push(chunk_of_segments) returns the keyframe frames that just became
final, flush() emits the tail. It reuses the exact offline component math
— :func:openfacefx.coarticulation._blend (the shared core of
build_viseme_curves) — over a bounded segment window, so the streamed
frames reproduce the offline solve.
Honesty — reproduces the offline solve WITHIN TOLERANCE, not bit-exactly.
The coarticulation dominance is a Laplacian bump D_i(t) = alpha·exp(-theta·|t −
c_i|) (coarticulation.py), exponential/infinite support, and the blend
normalizes over every segment. Bounded memory (pruning old segments) and a
finite look-ahead (dropping far-future ones) therefore both omit exponentially
small tails, so no finite window is bit-identical to
generate_from_alignment — that is fundamental to this dominance model, not an
implementation gap. It converges fast, though: the per-frame error from a window
W seconds wide is bounded by O(exp(-theta·W)) (slowest θ≈2.9/s for a long
vowel), so W≈1.5 s → ~1e‑2 (≈ the RDP epsilon), W≈3 s → ~1e‑4 (≈ the 4‑dp keyframe
grid), W≈4.5 s → ~1e‑6. look_ahead is the single latency↔fidelity dial:
0 = zero latency / no anticipatory coarticulation (causal only); larger = more
anticipation, tighter to offline. There is one exact case: when the window covers
the whole clip (look_ahead and back_span ≥ clip duration) the per-frame
blend is bit-identical to offline. Chunk boundaries never matter — the same
clip pushed in 1 or K chunks yields bit-identical frames (windows are selected
by frame time, not by arrival).
Causal only: a frame, once emitted, is never revised, and its value depends only
on inputs within its window — a later chunk cannot alter an already-emitted
keyframe (that immutability is the flip side of the tolerance). The optional
causal_smooth is a past-only one-pole filter, distinct from the offline
symmetric :func:openfacefx.postprocess.smooth_matrix (which reads both ways and
so is not reproducible causally). Network transport is out of scope — this is an
in-process generator.
Frame = Tuple[float, np.ndarray]
module-attribute
¶
DEFAULT_LOOK_AHEAD = 0.5
module-attribute
¶
DEFAULT_BACK_SPAN = 2.0
module-attribute
¶
StreamingGenerator(fps: float = 60.0, mapping=None, params: Optional[CoartParams] = None, *, look_ahead: float = DEFAULT_LOOK_AHEAD, back_span: float = DEFAULT_BACK_SPAN, causal_smooth: float = 0.0)
¶
Incremental coarticulation over pushed chunks with O(window) memory.
push(segments) cooks the new :class:~openfacefx.alignment.PhonemeSegment
chunk (the same silence-absorb + diphthong-split _preprocess does, applied
causally) and returns the frames now covered by the look-ahead; flush()
(alias close()) emits the tail. look_ahead / back_span are the
future / past window extents in seconds; causal_smooth is a past-only
one-pole time constant (0 = off). Reuse :func:frames_to_track to assemble
the emitted frames into a :class:~openfacefx.curves.FaceTrack.
Source code in src/openfacefx/streaming.py
fps = float(fps)
instance-attribute
¶
mapping = mapping
instance-attribute
¶
params = params or CoartParams()
instance-attribute
¶
look_ahead = max(float(look_ahead), 0.0)
instance-attribute
¶
back_span = max(float(back_span), 0.0)
instance-attribute
¶
causal_smooth = max(float(causal_smooth), 0.0)
instance-attribute
¶
target_names = list(mapping.target_names) if mapping is not None else list(VISEMES)
instance-attribute
¶
n_targets = len(self.target_names)
instance-attribute
¶
close = flush
class-attribute
instance-attribute
¶
buffered_segments: int
property
¶
Cooked segments currently retained — O(window), for the memory test.
push(segments) -> List[Frame]
¶
Feed a chunk of segments; return the frames finalized by it.
Source code in src/openfacefx/streaming.py
flush() -> List[Frame]
¶
Cook the held-back last segment, emit every remaining frame.
Source code in src/openfacefx/streaming.py
frames_to_track(frames: List[Frame], fps: float, mapping=None, epsilon: float = 0.015) -> FaceTrack
¶
Assemble emitted (time, values) frames into a
:class:~openfacefx.curves.FaceTrack via the same reduce_to_track the
offline pipeline uses — so a full-window stream reduces to the same keyframes
as generate_from_alignment.