# Omni-only Turn-Taking Experiment This project tests whether **Qwen2.5-Omni-3B can replace both the handcrafted audio/visual feature extractors and the downstream RNN/Transformer used by EgoSpeak** for egocentric turn-taking anticipation. The main experiment is deliberately simple: ```text causal audio + video up to time t | v Qwen2.5-Omni-3B Thinker | fused state q_t [2048] | v trainable prediction head | v future logits [10 horizons, 3 classes] ``` There is **no RNN after Qwen**. The Omni Thinker is the temporal model. The only supervised component in the first experiment is a small `10 x 3` classification head. ## Current status | File | Status | Purpose | |---|---|---| | `smoke_test.py` | Verified on one NVIDIA L20 | Text and 12-second EasyCom audio-video inference | | `easycom_manifest.py` | Implemented, awaiting dataset-side run | Discover one-minute clips, preserve session timecodes, and check 5 Hz targets | | `qwen_omni_features.py` | Implemented, not executed here | Load only the 3B Thinker and extract fused states from one AV window | | `streaming_turn_model.py` | Implemented | Produce one causal Omni state every 200 ms, with causal-prefix and open-stream modes | | `TurnPredictionHead` | Defined, untrained | Map `[2048]` to `[10, 3]` logits | | `train_turn_head.py` | Implemented | Train the frozen-Omni anticipation head with the official EasyCom split | | `infer_turn_head.py` | Implemented | Save EgoSpeak-format predictions and reproduce per-horizon AP/mAP | The official full model, Thinker input path, embedded audio, video decoding, and FlashAttention-2 have passed a 12-second EasyCom smoke test on one NVIDIA L20. Streaming extraction, cache equivalence, target alignment, training, and paper metrics remain unverified. ## Why Omni-only instead of Omni + RNN EgoSpeak separately extracts visual and audio features and then uses GRU, LSTR, or Mamba for temporal modeling: ```text ResNet50 video feature + wav2vec2 audio feature -> temporal model -> prediction ``` Qwen2.5-Omni already contains: - audio and visual encoders; - joint audio-video temporal positions; - a causal decoder-only Thinker with KV cache; - pretrained acoustic, visual, and semantic knowledge. Adding a GRU after a continuously processed Omni sequence would model history twice and would make it harder to attribute an improvement to Omni. The primary comparison is therefore: ```text EgoSpeak: ResNet50 + wav2vec2 + GRU Ours: Qwen2.5-Omni Thinker + small prediction head ``` An Omni-feature-plus-GRU model can be kept as an ablation, but it is not the main architecture. ## EgoSpeak contract that this project reproduces The inspected reference repository is `/Volumes/760P/Code/EgoSpeak`. It contains no Qwen or Omni implementation and no raw-media feature extraction scripts. It distributes precomputed arrays instead: ```text DATASET/ |- rgb_kinetics_resnet50/ # [T, 2048] |- audio_wav2vec2/ # [T, 5120] `- target_perframe/ # [T, 3] ``` The useful part for this project is the exact task interface. ### Time and classes Both EasyCom and the EgoSpeak `EGO4DSHUFFLE` split use **5 FPS**, so one task timestep is **0.2 seconds**. The three output columns are: ```text 0: background 1: camera wearer speaking 2: other people speaking ``` They are not strictly one-hot. Background is exclusive, but simultaneous camera-wearer and other-person speech is stored as `[0, 1, 1]`. The inspected EasyCom features contain 7,711 such overlap steps. Preserve the full multi-positive target rather than applying `argmax`. Relevant EgoSpeak files: - `LSTR/data/data_info.json`: FPS, classes, and session splits; - `MiniROAD/configs/EasyCom/miniroad_aa_easycom.yaml`: 128-step training windows, stride 4, three classes, and anticipation length 10; - `MiniROAD/datasets/dataset.py`: feature loading and future-label indexing; - `MiniROAD/criterions/loss.py`: anticipation loss; - `MiniROAD/trainer/eval.py`: per-horizon AP and mean anticipation mAP. ### Future target alignment For current task index `i`, the prediction target is: ```python target[i + 1 : i + 11] ``` Thus the ten output rows represent: ```text t+0.2, t+0.4, t+0.6, ..., t+2.0 seconds ``` If `states[i]` is verified to correspond to EgoSpeak task index `i`, the offline head-training tensors should be constructed as follows: ```python target = target_perframe # [T, 3], multi-positive usable = min(len(states), len(target)) - 10 x = states[:usable] # [N, 2048] y = stack( [target[h : h + usable] for h in range(1, 11)], axis=1, ) # [N, 10, 3] ``` Do not silently assume that the first saved Omni state matches label index zero. EgoSpeak does not publish the script that generated its original ResNet/wav2vec2 features, so the exact zero-time convention is not documented. Before training, inspect a short clip with known speech boundaries and test the candidate mappings `states[i] -> target[i]` and `states[i] -> target[i+1]`. Store the selected offset in every feature file/checkpoint. ### Splits Use the published split, not a random frame split. EasyCom: - train, preserving EgoSpeak's list: `Session_7`, `Session_11`, `Session_10`, `Session_6`, `Session_9`, `Session_4`, `Session_8`, `Session_5`, `Session_12`; - test, preserving EgoSpeak's list: `Session_3`, `Session_2`, `Session_1`. This is the **EgoSpeak comparison split**. The EasyCom dataset repository itself recommends a different train/validation/test split; do not substitute that split when reproducing EgoSpeak Table 2. Ego4D Audio-Visual Diarization: - use the `EGO4DSHUFFLE` key; - 346 training clips; - 87 test clips; - each clip is approximately five minutes. Never mix timesteps from the same session across training and validation. ## EasyCom raw-media layout and manifest The extracted release used here has this relevant structure: ```text extracted_easycom/ `- Main/ |- Video_Compressed/ | |- Session_1/ | | |- 00-00-000.mp4 | | |- 01-00-000.mp4 | | `- ... | `- Session_2 ... Session_12/ |- Glasses_Microphone_Array_Audio/ |- Close_Microphone_Audio/ |- Speech_Transcriptions/ `- Metadata/ ``` `Video_Compressed` is the AR-glasses wearer's egocentric stream with embedded stereo AAC audio. Each session is divided into approximately one-minute clips; the `mm-ss-SSS` filename is the clip's start time on the original recording timeline. Some intervals are absent because EasyCom redacted one-minute segments. These raw time gaps are expected. EgoSpeak does not preserve those empty intervals. Its appendix states that it crops every EasyCom clip to retain only the 1920-pixel-wide video region and then merges the retained clips into one dense video per session. Therefore the EgoSpeak-compatible label time for a clip is its cumulative position in the concatenation, not the `mm-ss-SSS` source time. The manifest stores both. Build a raw-video manifest and, when the EgoSpeak labels are available, check their duration at 5 Hz: ```bash python easycom_manifest.py \ --dataset-root /data1/yfzx/wangx/Datasets/easycom/extracted_easycom \ --target-root /path/to/EasyCom/target_perframe \ --output manifests/easycom.json ``` Omit `--target-root` for the first media-only inventory. The manifest reports raw-source discontinuities as informational and computes each clip's dense EgoSpeak concatenation offset and corresponding 5 Hz target index. For the audio-only ablation, use the same embedded glasses audio as A+V (extract it to mono WAV if needed); do not switch to close microphones, because that changes the sensor and makes the modality ablation unfair. Audit downloaded EgoSpeak arrays, and optionally compare every session length against the raw manifest: ```bash python audit_egospeak_features.py \ --feature-root /path/to/EgoSpeak/dataset/EasyCom \ --raw-manifest manifests/easycom.json \ --output reports/egospeak_easycom_audit.json ``` The inspected release contains 95,453 synchronized steps across all 12 sessions: 5.302944 hours at 5 Hz. Every session has identical RGB, flow, audio, and target length. Shapes are `[T,2048]`, `[T,2048]`, `[T,5120]`, and `[T,3]`. ## Recommended comparison matrix | Experiment | Visual sampling | Audio | Output rate | |---|---:|---|---:| | EgoSpeak official A+V | 5 Hz ResNet features | 5 Hz wav2vec2 features | 5 Hz | | Omni audio-only | none | raw glasses-wearer waveform | 5 Hz | | Omni A+V primary | 2 FPS | raw embedded waveform | 5 Hz | | Omni A+V FPS ablation | 5 FPS | raw embedded waveform | 5 Hz | Two FPS is the primary Omni setting because it matches Qwen's default video sampling and permits longer context. Five FPS is the visual-rate ablation. Input feature rates need not match EgoSpeak; the split, causal prediction times, three classes, ten target horizons, and evaluator must match exactly. ## Prepare an EgoSpeak-compatible EasyCom session Do not pass the raw one-minute files independently to Omni and do not preserve the gaps implied by their filenames. EgoSpeak concatenates the retained clips into a dense session timeline and crops the participant-ID banner on the left. `prepare_easycom_session.py` reproduces this policy and creates videos whose stored frames already lie on a fixed session-global 2 FPS or 5 FPS grid. This prevents Qwen's per-window video loader from choosing a new adaptive set of frames for every overlapping prefix. Session 1 has been checked against the local release: ```text 24 retained clips 27,768 source frames at 20 FPS / 4 frames per task step = 6,942 steps at 5 Hz = target_perframe/Session_1.npy length exactly ``` Preview the commands and validate frames against the target without encoding: ```bash python prepare_easycom_session.py \ --session-dir /Volumes/MX500B/Code/easycom/Main/Video_Compressed/Session_1 \ --output-dir prepared_easycom/Session_1 \ --target /Volumes/MX500B/Code/EgoSpeak/dataset/EasyCom/target_perframe/Session_1.npy \ --fps 2 5 \ --dry-run ``` Remove `--dry-run` to run FFmpeg. The outputs are: ```text Session_1_2fps.mp4 primary Omni A+V input Session_1_5fps.mp4 visual-rate ablation Session_1.json clip order, exact T, crop and sampling provenance Session_1.ffconcat reproducible dense concatenation order ``` The target length must equal the number of complete 5 Hz intervals, `floor(sum(source_frames) / 4)`. If a session ends with one to three residual 20 Hz frames, the target-derived duration discards only that incomplete final interval; the discarded frame count is recorded in the sidecar. Each clip's decoded video and audio are trimmed to the exact duration implied by its video frame count before concatenation. This prevents AAC priming/tail padding from accumulating at the 24 clip boundaries and causing progressive audio/video drift. The session-global visual grid is padded defensively at EOF and then trimmed by frame index, so it contains exactly `ceil(target_steps / 5 * video_fps)` frames on every FFmpeg version. On the remote L20 machine, use its own dataset paths. For example: ```bash python prepare_easycom_session.py \ --session-dir /data1/yfzx/wangx/Datasets/easycom/extracted_easycom/Main/Video_Compressed/Session_1 \ --output-dir /data1/yfzx/wangx/Datasets/easycom/omni_prepared/Session_1 \ --target /path/to/EgoSpeak/dataset/EasyCom/target_perframe/Session_1.npy \ --fps 2 5 ``` This is the command that performs the potentially long FFmpeg processing; it has not been run by the code authoring workflow. If encoding completed but a later validation/metadata step failed, update the script and reuse the existing MP4 without encoding it again: ```bash python prepare_easycom_session.py ... --fps 2 --reuse-existing ``` ## What Qwen's native 2-second streaming design means Qwen2.5-Omni was designed for **block-wise multimodal prefill**. Its [technical report](https://arxiv.org/html/2503.20215) states that the audio and visual encoders are modified for block-wise processing and that audio self-attention is performed in independent two-second blocks. The released [3B configuration](https://huggingface.co/Qwen/Qwen2.5-Omni-3B/raw/main/config.json) uses `seconds_per_chunk=2`, `position_id_per_seconds=25`, and audio `n_window=100`. Conceptually, a native online input is organized as follows: ```text open one user/media turn encode/append AV [0,2] -> commit Thinker KV encode/append AV [2,4] -> commit Thinker KV encode/append AV [4,6] -> commit Thinker KV ... close the media turn -> generate text and/or speech ``` Within each temporal group, video tokens precede the aligned audio tokens. TMRoPE assigns 25 temporal positions per second, or 50 positions per native two-second group, so audio and video share a physical-time coordinate system. Completed blocks are appended to one causal Thinker sequence; the long-range history is modeled by the Thinker rather than by cross-block full attention in the modality encoders. This architecture is block-causal, not frame-causal. Attention inside an audio block is non-causal. It is therefore invalid to encode the complete `[0,2]` block and claim that its representation at 0.2 seconds saw no future: ```text encode complete [0,2] AV -> read state at 0.2 s # future leakage ``` For this project's 5 Hz targets, the current incomplete block must instead be encoded only through the prediction time. These 0.2-second partial-block readouts are an extension made for EgoSpeak; the Qwen paper does not define a canonical hidden state every 200 ms. The public Transformers and local Qwen web-demo APIs normally receive a completed media object and call the processor/model once. They retain the model's block-wise masks and token organization, but do not expose the full persistent live-session implementation used by the hosted real-time product. ## Native Qwen streaming versus `causal-prefix` The two extraction modes are both causal with respect to raw observations, but they define different model inputs. `causal-prefix` is a sliding-window reference invented for this experiment; it is not Qwen's native streaming execution model. | Property | `causal-prefix` | Native Qwen streaming idea | |---|---|---| | Session | New inference at every 0.2 s | One open media turn | | Media organization | One complete `[max(0,t-M),t]` item | Fixed source-aligned 2 s blocks | | Modality encoding | Re-encode the complete window | Encode/commit each completed block once | | Thinker KV | Start empty for every state | Append to persistent KV | | TMRoPE | Recomputed from each window origin | Advances with the stream | | History | Exact rolling `M` seconds | Accumulates until a serving/context policy intervenes | | 5 Hz readout | Close/read a new turn each time | Not specified by the original model | The difference becomes especially important after a sliding window fills. At `t=20.8` with `M=20`, `causal-prefix` constructs one new media item covering: ```text [0.8,20.8] -> encoder blocks [0.8,2.8], [2.8,4.8], ..., [18.8,20.8] ``` At `t=21.0`, every encoder boundary moves again: ```text [1.0,21.0] -> encoder blocks [1.0,3.0], [3.0,5.0], ..., [19.0,21.0] ``` Native streaming keeps the source-aligned partition instead: ```text [0,2], [2,4], ..., [18,20], current partial [20,20.8] ``` Because Qwen's audio encoder uses block-local non-causal attention, moving a block boundary changes the representation even when most raw samples are the same. Window-relative TMRoPE, convolution/padding boundaries, chat closure, and BF16/attention execution add further differences. This is why exact KV replay can be numerically identical to its own from-scratch replay while still differing from `causal-prefix`; the modes are evaluating different token and encoder histories. Use the modes according to the scientific question: - use `open-stream-replay` for the primary online experiment, training, and deployment-aligned evaluation; - use `causal-prefix` as an expensive continuous-window reference and short-clip ablation; - never train a head on one representation and deploy it on the other without retraining, because their feature distributions are not interchangeable. ## Causal 5 Hz extraction modes `streaming_turn_model.py` implements two modes. ### 1. `causal-prefix`: continuous-window reference At every 200 ms, independently process the causal context ending at the current time: ```text [max(0, t-20 s), t] AV -> Omni -> q_t ``` Properties: - strict no-future input; - one continuous media window per prediction; - no cross-forward cache; - very expensive because overlapping context is recomputed; - useful for checking continuous sliding-window semantics and causality; - not the native Qwen block-streaming execution model. Example: ```bash python streaming_turn_model.py \ --video /path/to/easycom_session.mp4 \ --use-audio-in-video \ --mode causal-prefix \ --start 0 \ --end 20 \ --memory-seconds 20 \ --output features/easycom_20s_prefix.pt ``` ### 2. `open-stream-replay`: recommended efficient mode This mode keeps one user/media turn open across the whole bounded-memory segment. The first block contributes the chat and AV opening markers once; later completed two-second blocks contribute only interleaved audio/video payload tokens: ```text open AV prefix completed [0,2] payload -> commit KV current [2,2.2] payload -> temporary branch -> close/read q_t current [2,2.4] payload -> temporary branch -> close/read q_t ... completed [2,4] payload -> commit KV AV/chat closure -> never committed; readout branch only ``` This matches the core Qwen design choices: fixed two-second AV groups, one open media turn, completed-block commits, and incremental Thinker KV. It makes two practical changes for this dataset: 1. the current 0--2 second partial block is evaluated speculatively every 0.2 seconds and discarded unless it reaches a complete block boundary; 2. the KV history is bounded and rebuilt from recent complete blocks rather than accumulating for an entire multi-minute session. Complete-block AV embeddings are retained in a bounded ring, so rolling rebuilds rerun only the Thinker and do not rerun the processor, audio tower, or video tower. It should therefore be described as **bounded Qwen-style open streaming**, not as bit-for-bit reproduction of an unbounded official serving session. The audio encoder accepts a shorter final chunk with padding and an actual-length mask. For the two-frame visual patch requirement, strict-grid mode duplicates only the most recent past frame when necessary. Run a four-second validation before using it for extraction: ```bash python streaming_turn_model.py \ --model-id /path/to/Qwen2.5-Omni-3B \ --video prepared_easycom/Session_1/Session_1_2fps.mp4 \ --use-audio-in-video \ --mode open-stream-replay \ --start 0 --end 4 \ --video-fps 2 \ --strict-fixed-video-grid \ --verify-every 1 \ --verify-continuous-every 1 \ --flash-attention-2 \ --output features/Session_1_open_first_4s.pt ``` `cache_replay` must be identical to replaying the same open stream from an empty cache. `continuous_window_semantics` compares it with one normal continuous Qwen AV input. Do not assume equivalence: the modality encoders are still invoked per tail block in the efficient path, so this numerical check is required even though artificial user-turn boundaries have been removed. ## Whole-session prediction and the 20-second limit Twenty seconds is a practical memory/context setting for the 3090, not the maximum video duration and not a limit specified by Qwen. The original append-only idea eventually reaches both linear KV-memory growth and the Thinker's 32,768-token context limit, so an unbounded multi-minute stream is not practical. The complete session is processed sequentially and a prediction is emitted every 0.2 seconds. `open-stream-replay` uses blockwise rolling memory. With a 20-second budget it rebuilds the open cache from the latest nine completed blocks (18 seconds) when a new block starts, then adds the current 0--2 second tail. Its effective history therefore varies from 18 to 20 seconds and never drops to an empty 0.2-second context at a 20-second boundary. The nine blocks' prepared AV embeddings and local TMRoPE coordinates are cached once; strict window replay still recomputes the Thinker KV so retained blocks no longer carry indirect influence from observations that fell out of the window. For a short native-stream fidelity check, set `--memory-seconds` to at least the tested clip duration. For example, the options `--start 0 --end 40 --memory-seconds 40` prevent a rolling rebuild during that 40-second clip. Do not extend this policy to complete EasyCom/Ego4D sessions: KV memory grows linearly, and this implementation clones the cache for speculative partial blocks and readout branches. For the paper experiment, drive the output length from the preparation sidecar rather than a floating MP4 duration: ```bash python streaming_turn_model.py \ --session-metadata prepared_easycom/Session_1/Session_1.json \ --use-audio-in-video \ --mode open-stream-replay \ --memory-seconds 20 \ --video-fps 2 \ --strict-fixed-video-grid \ --verify-every 25 \ --verify-continuous-every 25 \ --output features/Session_1_omni_2fps.pt ``` The sidecar selects the matching fixed-FPS MP4 and sets `N` to its exact `num_steps`. Alternatively pass `--video ... --target ...` explicitly. A raw 20 FPS MP4 with `--video-fps 2` is rejected when `--strict-fixed-video-grid` is enabled. In strict A+V mode the extractor does not delegate temporal frame selection to the generic video utility. It reads the already-fixed MP4 grid explicitly and assigns both video frames and audio samples with half-open intervals `[start, end)`. Thus a frame exactly on a two-second boundary belongs only to the following block. It duplicates the most recent available frame when the visual tower needs an even frame count. Audio is loaded once at 16 kHz and sliced at the same causal boundary. The processor still receives `use_audio_in_video=True`, so Qwen's audio/video token interleaving is retained. Long runs use `tqdm` and update the progress bar after every completed state, showing percentage, media time, elapsed time, ETA, and throughput. Pass `--no-progress` to disable it. The Thinker-only path also suppresses Qwen's irrelevant speech-output system prompt warning. Before a full session, run only the first four seconds and inspect both check types: ```bash python streaming_turn_model.py \ --model-id /data1/yfzx/wangx/Models/Qwen2.5-Omni-3B \ --video /data1/yfzx/wangx/Datasets/easycom/omni_prepared/Session_1/Session_1_2fps.mp4 \ --use-audio-in-video \ --mode open-stream-replay \ --start 0 --end 4 \ --video-fps 2 \ --strict-fixed-video-grid \ --verify-every 1 \ --verify-continuous-every 1 \ --flash-attention-2 \ --output features/Session_1_first_4s_check.pt ``` Do not launch all sessions unless `cache_replay` cosine is nearly 1. For `open-stream-replay`, a materially lower `continuous_window_semantics` cosine means that the custom payload stitching is not equivalent to one continuous Qwen input; it must not be dismissed as a media-turn-boundary effect because this mode intentionally keeps a single media turn open. Video can be sampled at 2 FPS while predictions remain at 5 Hz. Audio and the causal boundary update every 200 ms; consecutive prediction times may use the same most-recent visual frame. A frame with timestamp later than `t` must never be included. ## Direct extraction without a prepared 2 FPS MP4 `streaming_turn_model_directly.py` removes the offline video-transcoding requirement. It reads original frame PTS with Decord, constructs a global 2 FPS lattice, maps every lattice point to the latest source frame whose PTS is not later than that point, and passes explicit frame lists to Qwen. Audio is decoded once to 16-kHz mono and sliced at exact causal boundaries. Therefore no FFmpeg `fps` filter or second H.264/AAC encode can alter the experiment. Validate one Ego4D clip without loading Qwen: ```bash python streaming_turn_model_directly.py \ --video /data/wangx/Datasets/ego4d/v1/clips/CLIP_UID.mp4 \ --target /data/wangx/Datasets/ego4d/ego4d_target_frame/CLIP_UID.npy \ --video-fps 2 \ --media-only ``` Run a short native persistent-KV check: ```bash python streaming_turn_model_directly.py \ --model-id /data/wangx/Models/Qwen2.5-Omni-3B \ --video /data/wangx/Datasets/ego4d/v1/clips/CLIP_UID.mp4 \ --end 40 \ --mode open-stream-replay \ --memory-seconds 0 \ --video-fps 2 \ --flash-attention-2 \ --device cuda:0 \ --output features/direct/CLIP_UID_first_40s.pt ``` Here `--memory-seconds 0` means that completed-block Thinker KV persists for the entire requested interval. Do not use this setting blindly for complete multi-minute recordings: it has linear KV growth and remains subject to the Thinker's context limit. A full bounded-memory dataset run should explicitly use the experimental budget, for example `--memory-seconds 20`. EasyCom can be read directly as the dense concatenation of retained clips; filename timecode gaps are ignored and the rightmost 1920x1080 region is kept: ```bash python streaming_turn_model_directly.py \ --model-id /path/to/Qwen2.5-Omni-3B \ --easycom-session-dir /path/to/Main/Video_Compressed/Session_1 \ --target target_frame/Session_1.npy \ --mode open-stream-replay \ --memory-seconds 20 \ --video-fps 2 \ --flash-attention-2 \ --device cuda:0 \ --output features/direct/Session_1.pt ``` The direct reader defaults to Decord. On NVIDIA systems, install `PyNvVideoCodec` and select the NVDEC backend explicitly: ```bash python -m pip install PyNvVideoCodec CUDA_VISIBLE_DEVICES=0 python streaming_turn_model_directly.py \ --model-id /path/to/Qwen2.5-Omni-3B \ --easycom-session-dir /path/to/Main/Video_Compressed/Session_1 \ --end 24 \ --mode open-stream-replay \ --memory-seconds 20 \ --video-fps 2 \ --frame-cache-size 64 \ --decoder pynvcodec \ --decoder-gpu-id 0 \ --flash-attention-2 \ --device cuda:0 \ --output features/direct/Session_1_24s_pynvcodec.pt ``` The NVDEC backend uses ffprobe for exact display-order PTS indexing, one reconfigurable `SimpleDecoder` for pixel decode, and the existing ffmpeg PCM audio path. `--decoder-gpu-id` is relative to `CUDA_VISIBLE_DEVICES`, so it is normally `0` even when the worker is assigned a nonzero host GPU. Decoder RGB conversion can differ slightly; validate a short Decord/PyNvVideoCodec pair before extracting every session, and do not mix decoder backends across train/test splits. For CPU Decord, the direct reader intentionally indexes each clip and releases that temporary reader, then keeps an LRU of at most two live readers during extraction. Each reader defaults to one decode thread. These limits avoid one worker retaining a thread pool for every one-minute EasyCom clip and can be overridden with `--decoder-threads` and `--max-open-readers`. The recommended full-run settings are: ```text --decoder decord --decoder-threads 1 --max-open-readers 2 --frame-cache-size 64 ``` Matching `states.shape[0]` to the target is necessary but not sufficient. The direct reader also validates EasyCom's 20-Hz source inventory, source PTS monotonicity, dense duration, crop bounds, audio sample count, and causal frame-to-grid mapping. The original prepare scripts remain useful only as an optional decode-speed/reproducibility cache. ## Saved streaming tensors Each `.pt` file contains: - `times`: `[N]`, prediction times in seconds; - `states`: `[N, 2048]`, causal fused Thinker states; - `label_indices`: `[0, ..., N-1]`, direct `target_perframe` row alignment; - `valid_10_horizon_steps`: `N-10`, because the last ten current rows do not have all future labels; - `mode`: `causal-prefix` or `open-stream-replay`; - `stream_representation`: explicit input-organization metadata; - `config`: step, native block, memory, FPS, and pixel settings; - `verification`: cached-versus-exact numerical checks; - `input_video_fps` and `video_sampling_grid`: sampling provenance; - `media_interval_policy`: fixed-grid AV uses half-open `[start, end)` ownership; - `logits`: optional `[N, 10, 3]` output if a trained head is supplied; - `probabilities`: optional softmax probabilities. ## Training the `10 x 3` head The head must be trained. It is not part of pretrained Qwen. The first experiment should freeze all Qwen parameters and train only: ```text LayerNorm(2048) -> Linear(2048, 1024) -> GELU -> Dropout -> Linear(1024, 30) -> reshape [10, 3] ``` `easycom_head.py` defines the head and loss; they remain re-exported by `streaming_turn_model.py` for compatibility: ```python from streaming_turn_model import ( TurnPredictionHead, egospeak_multihorizon_loss, ) ``` The supplied LSTR config uses Adam, 50 epochs, learning rate `7e-5`, weight decay `5e-5`, dropout `0.2`, and 10 warmup epochs followed by cosine decay. `train_turn_head.py` uses those defaults. Its batch size is 512 causal states, not LSTR's 16 temporal windows, because the two model inputs have different shapes. Train once on the official nine sessions with a fixed 50-epoch protocol: ```bash python train_turn_head.py \ --feature-root features/open_2fps \ --target-root target_frame \ --output checkpoints/open_2fps_head.pth \ --device cuda:0 ``` The default loss is `lstr-mce`, exactly matching LSTR's MCE treatment of a multi-positive `[0,1,1]` row: it sums both active negative-log-softmax terms without normalizing the target. The previous L2-normalized objective belongs to MiniROAD and is available only as the explicit `--loss miniroad-normalized` ablation. Recommended experimental procedure: 1. Freeze Qwen and extract all training-session states offline. 2. Verify state/label time offset and truncate only the final ten invalid current positions. 3. Keep EgoSpeak targets as float multi-positive tensors `[N, 10, 3]`; never convert them with `argmax`. 4. For comparison with the supplied LSTR shell, train with the default `egospeak_multihorizon_loss`/`lstr-mce` objective. 5. Select hyperparameters using only training sessions. For EasyCom, reserve a training session for development or use session-level cross-validation; never tune on Sessions 1--3. 6. After choosing the epoch/hyperparameters, optionally retrain on all nine EasyCom training sessions and evaluate the fixed model once on the test set. Class-balanced/focal loss, a learned turn-query token, and LoRA are later ablations. They should not replace the frozen-Omni linear/MLP-head baseline. ## EgoSpeak-compatible evaluation Run inference on the official test sessions (`Session_3`, `Session_2`, and `Session_1`): ```bash python infer_turn_head.py \ --feature-root features/open_2fps \ --target-root target_frame \ --checkpoint checkpoints/open_2fps_head.pth \ --output-dir results/open_2fps_head \ --device cuda:0 ``` This writes `pred/Session_X.npy` with the exact LSTR/EgoSpeak layout `[target_time, class, future_horizon]`, i.e. `[T,3,10]`, plus `metrics.json`. For every valid current timestep, the head itself produces probabilities shaped: ```text [number of timesteps, 10 future horizons, 3 classes] ``` Then reproduce EgoSpeak's Table 2 procedure: 1. for each horizon independently, concatenate test-session predictions and ground truth; 2. compute per-class frame-level average precision; 3. average the three class AP values to obtain horizon mAP; 4. report all ten horizon mAP values; 5. average the ten horizon mAP values for the final score. With EgoSpeak's `ignore_index=-100`, the released evaluator does not remove class index zero, so background is included. The script reports two protocols: - `egospeak_full_timeline`: the same full `[T,3,10]` array and evaluator as LSTR, with zeros where a prediction would require pre-session context; - `causal_valid_only`: excludes only those unavailable first `h` rows for horizon `h`. This is the primary strictly causal score. The difference involves at most the first ten rows of each session, but both are saved so the comparison is explicit. The convenience wrapper mirrors EgoSpeak's train/inference shells: ```bash chmod +x run_easycom_head_experiment.sh CUDA_VISIBLE_DEVICES=0 ./run_easycom_head_experiment.sh all ``` Reference Table 2 targets from the EgoSpeak paper: - EasyCom GRU A+V: **60.6 mean mAP**; - EasyCom Transformer A+V: **58.7 mean mAP**; - Ego4D Transformer audio: **69.2 mean mAP**; - Ego4D Transformer A+V: **69.0 mean mAP**; - Ego4D GRU audio: **69.2 mean mAP**; - Ego4D GRU A+V: **68.2 mean mAP**. Do not compare these numbers with EgoSpeak Table 3 target-speaker-only AP; Table 3 uses a different metric. ## Experiments that demonstrate Omni's semantic advantage The headline comparison alone cannot establish that improvements come from semantics. Run the following controlled experiments: 1. **Omni audio vs Omni audio+video**: measure whether visual grounding helps. 2. **Per-horizon curve**: semantic completion should be especially visible at longer horizons such as 1.2--2.0 seconds. 3. **Context length**: compare 4, 10, 15, and 20 seconds without adding an RNN. 4. **Layer ablation**: compare audio-encoder output and several Thinker layers, because an intermediate layer may be more discriminative than the final generation-oriented layer. 5. **Semantic-completion subset**: contrast completed utterances with hesitation/incomplete pauses while matching pause duration and basic prosody as closely as possible. 6. **Question/statement/backchannel subsets**: report target-speaker AP and premature-interruption rate on linguistically meaningful events. 7. **Causal transcript baseline**: compare Omni audio with causal ASR text plus a language model to isolate lexical/semantic contribution. The frozen-Omni head is the cleanest representation test. LoRA can be reported as a second-stage ceiling, not as the only result. ## Single-window feature inspection `qwen_omni_features.py` is useful for inspecting a short window or layer, but it is not the main whole-session experiment. If audio is embedded in the video: ```bash python qwen_omni_features.py \ --video /path/to/window.mp4 \ --use-audio-in-video \ --output features/window.pt ``` If audio and video are separate: ```bash python qwen_omni_features.py \ --video /path/to/window.mp4 \ --audio /path/to/window.wav \ --output features/window.pt ``` Do not provide the same audio both as a separate file and via `--use-audio-in-video`. Saved single-window fields are: - `token_features`: `[B, token_length, 2048]`; - `last_token`: `[B, 2048]`; - `tail_mean`: `[B, 2048]`; - `attention_mask`: valid-token mask. ## Installation Install `ffmpeg`, then: ```bash pip install -r requirements.txt ``` The implementation is pinned to `transformers==4.52.3` because the open-stream replay path uses version-specific Thinker internals. On Linux, `qwen-omni-utils[decord]` is recommended for faster video decoding. On a compatible NVIDIA GPU, optionally install `flash-attn` and pass `--flash-attention-2`. ## Ego4D Audio-Visual Diarization download Do not download the complete Ego4D release for this experiment. Download only the compressed Audio-Visual Diarization benchmark clips and annotations: ```bash ego4d \ --output_directory=/path/to/ego4d \ --datasets clips annotations \ --benchmarks AV ``` The approximately 1 TB figure on the Ego4D download page refers to all benchmark clips combined, not only the AV subset. The annotated AVD subset has about 572 five-minute clips (roughly 50 hours). EgoSpeak uses a custom 346/87 train/test split, about 36.1 hours total. Reproducing its Table 2 requires the exact IDs in EgoSpeak's `EGO4DSHUFFLE` split. ## Required validation before a full run Do not launch full EasyCom/Ego4D extraction until all of these pass: - no input audio sample or video frame has timestamp later than prediction time `t`; - `states` and `target_perframe` lengths/time origins are reconciled and the chosen label offset is saved; - open-stream cache replay is exactly equivalent to its from-scratch replay on short clips; - open-stream and causal-prefix predictions are compared on identical clips; - a 20-second run fits the 3090 with the selected pixel budget and attention implementation; - saved outputs contain exactly ten future horizons in the order 0.2--2.0 s; - the local metric reproduces EgoSpeak's evaluator on known predictions; - test session IDs never enter training, validation, normalization statistics, or hyperparameter selection. Once these checks pass, the first publishable experiment is: ```text frozen Qwen2.5-Omni-3B Thinker + trained 10x3 head + bounded Qwen-style 2-second open streaming + 20-second rolling causal memory + full-session 5 Hz output + exact EgoSpeak split and Table 2 metric ```