dragonflight/services/framecache/client/fc_pipe.c
ZGaetano 2f37119379 fix(framecache): frame-coupled audio — video+audio in ONE ring entry
Re-engineer the framecache so each video frame carries its own SDI-embedded
audio through ONE transport, eliminating the "audio ahead of video" offset at
the root: there is no longer a second independent audio buffer/FIFO that can
race ahead of video.

slot.h (FC_VERSION 1 -> 2):
  - Per ring entry data region is now [video frame_size][audio FC_MAX_AUDIO_BYTES].
  - fc_frame_t: the former _pad u32 is REUSED as audio_size (header still 24B).
  - Header gains audio_max_bytes / audio_rate / audio_channels (self-describing).
  - New fc_entry_stride()/fc_frame_audio() helpers; shm size includes audio.
  - Readers/writer check version == FC_VERSION and FAIL SAFE on mismatch so an
    old reader against a new writer (or vice-versa) refuses rather than misparses.

slot.c: populate audio header fields; add fc_slot_write_av(); version gate in open.
fc_client.[ch]: fc_frame_ref_t gains audio/audio_size; copy buffer holds
  video+audio; both copied from the SAME entry in one read -> frame-locked.
fc_pipe.c: now <slot_id> <wait_ms> <audio_fifo_path>; per ring entry writes
  video -> stdout AND that frame's audio -> the audio FIFO IN LOCKSTEP from one
  cursor read (no second buffer to drift). Auto-reattaches FIFO on EPIPE.

deltacast-bridge:
  - SILENT-AUDIO FIX: audio_extract_init now configures ONLY pAudioChannels[0]
    of group 0 as one stereo pair (Mode=STEREO, BufferFormat=AF_16, pData=buf),
    leaving pAudioChannels[1] ZEROED, exactly like Deltacast's own FFmpeg fork
    (libavdevice/videomaster_common.c init_audio_info). The prior JOINED code
    ALSO set channel[1].Mode/BufferFormat=STEREO/AF_16, declaring a second pair
    the signal does not carry -> VHD_SlotExtractAudio returned zero samples ->
    -91 dB silent audio. DataSize is (re)set to capacity before each extract.
  - VHD_SDI_SP_INTERFACE now set from the channel-detected interface
    (VHD_SDI_CP_INTERFACE) before StartStream, per the same fork — required for
    embedded-audio extraction on JOINED SDI streams.
  - fc_writer.[ch]: add fc_writer_write_av(); struct/stride bumped to v2.
  - video_thread (framecache path) extracts each frame's audio from the SAME
    locked JOINED slot and writes BOTH via fc_writer_write_av. Silence fallback
    at the source: a frame with no embedded audio gets one frame-interval of
    silence so the audio timeline length always equals the video timeline length.
  - The separate audio FIFO + audio_thread + apcm ring are retained ONLY for the
    legacy (-DLEGACY_FIFO / framecache-unreachable) fallback; on the primary
    framecache path the bridge no longer owns the audio FIFO.

capture-manager.js: deltacast/sdi framecache branch now CREATES the audio FIFO
  and passes its path to fc_pipe (argv[3]); ffmpeg keeps two raw inputs
  (rawvideo pipe:0 + s16le 48k input 1) but both are now fed frame-locked from
  the same ring entry. Stale-audio pre-flush retained as harmless safety.

All changes versioned; mismatched binaries refuse to attach (fail safe).
2026-06-05 12:46:22 +00:00

206 lines
8.5 KiB
C

/**
* fc_pipe.c — Framecache slot → stdout(video) + FIFO(audio) pipe adapter.
*
* FRAME-COUPLED AUDIO (FC_VERSION 2):
* Each framecache ring entry carries the VIDEO frame AND that frame's
* SDI-embedded AUDIO together. fc_pipe reads ONE entry per loop iteration
* and writes:
* - the video bytes → stdout (ffmpeg rawvideo input 0 / pipe:0)
* - the audio bytes → audio FIFO (ffmpeg s16le input 1)
* IN LOCKSTEP from the SAME cursor read. Because both come out of the same
* ring entry in the same iteration, audio can never drift ahead of (or
* behind) its video frame — there is no second independent buffer/transport
* to race. This eliminates the constant "audio ahead of video" offset at the
* root.
*
* capture-manager.js spawns this process, pipes its stdout to ffmpeg input 0,
* and passes the audio FIFO path it created as argv[3]; ffmpeg reads that FIFO
* as input 1.
*
* Each consumer instance has its own independent read cursor, so multiple
* fc_pipe processes reading from the same slot never interfere with each other
* (growing + proxy + HLS all read the same SDI signal simultaneously).
*
* Usage:
* fc_pipe <slot_id> [wait_ms] [audio_fifo_path]
*
* audio_fifo_path optional: if omitted (or "-"), audio is NOT emitted and
* fc_pipe behaves video-only (legacy / network video-only sources).
*
* Audio FIFO lifecycle:
* - Opened O_WRONLY|O_NONBLOCK with retry; until a reader (ffmpeg input 1)
* attaches the open returns ENXIO. We keep delivering VIDEO meanwhile so
* ffmpeg makes progress and opens the FIFO. Audio for those very first
* pre-roll frames is dropped (sub-frame startup gap inside pre-roll).
* - Once open we switch the fd to blocking and write each frame's audio with
* the same write_all() as video, keeping them coupled. If the signal has
* no embedded audio on a frame (audio_size 0) we synthesize exactly that
* frame's worth of silence so ffmpeg input 1 never starves and the audio
* timeline length always equals the video timeline length (no drift).
* - On audio FIFO EPIPE (ffmpeg input 1 reader died, e.g. session restart),
* we close and re-open the FIFO; VIDEO delivery is unaffected.
*
* Terminates on:
* - SIGTERM / SIGINT (clean stop from capture-manager)
* - stdout EPIPE (ffmpeg exited)
* - Slot disappears (bridge stopped)
*
* Exit codes:
* 0 clean stop (SIGTERM)
* 1 slot not found within wait_ms
* 2 stdout write error (EPIPE)
*/
#include "../src/slot.h"
#include "fc_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <time.h>
#include <fcntl.h>
static volatile int g_stop = 0;
static void on_signal(int s) { (void)s; g_stop = 1; }
/* Write all bytes to fd (blocking). Returns 0 on success, -1 on EPIPE/error. */
static int write_all_fd(int fd, const void *buf, size_t len) {
const uint8_t *p = (const uint8_t *)buf;
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, p + off, len - off);
if (n > 0) { off += (size_t)n; continue; }
if (n < 0 && errno == EINTR) continue;
return -1; /* EPIPE or other fatal error */
}
return 0;
}
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <slot_id> [wait_ms] [audio_fifo_path]\n", argv[0]);
return 1;
}
const char *slot_id = argv[1];
uint64_t wait_ms = argc >= 3 ? (uint64_t)atoll(argv[2]) : 30000;
const char *audio_fifo = (argc >= 4 && strcmp(argv[3], "-") != 0) ? argv[3] : NULL;
signal(SIGTERM, on_signal);
signal(SIGINT, on_signal);
signal(SIGPIPE, SIG_IGN); /* detect EPIPE via write() return value */
/* Set stdout to binary/blocking mode — no newline translation */
fcntl(STDOUT_FILENO, F_SETFL,
fcntl(STDOUT_FILENO, F_GETFL, 0) & ~O_NONBLOCK);
fprintf(stderr, "[fc_pipe] opening slot '%s' (wait %llums) audio_fifo=%s\n",
slot_id, (unsigned long long)wait_ms,
audio_fifo ? audio_fifo : "(none)");
fc_consumer_t *c = fc_consumer_open(slot_id, wait_ms);
if (!c) {
fprintf(stderr, "[fc_pipe] slot '%s' not found within %llums\n",
slot_id, (unsigned long long)wait_ms);
return 1;
}
fprintf(stderr, "[fc_pipe] slot open, streaming video→stdout%s\n",
audio_fifo ? " + audio→FIFO (frame-coupled)" : "");
int afd = -1; /* audio FIFO fd, -1 until a reader attaches */
int logged_aud = 0;
uint64_t frames_out = 0;
uint64_t total_dropped = 0;
uint64_t audio_bytes = 0;
uint64_t audio_gaps = 0; /* frames where embedded audio was absent (silence filled) */
while (!g_stop) {
fc_frame_ref_t ref;
int rc = fc_consumer_read(c, &ref, 2000 /* 2s timeout */);
if (rc == FC_TIMEOUT) continue;
if (rc == FC_ERROR) break;
if (rc == FC_LAPPED) {
total_dropped = fc_consumer_dropped(c);
fprintf(stderr, "[fc_pipe] WARNING: frame lapped mid-read — total dropped: %llu\n",
(unsigned long long)total_dropped);
continue;
}
if (rc == FC_DROPPED) {
total_dropped = fc_consumer_dropped(c);
fprintf(stderr, "[fc_pipe] WARNING: consumer fell behind — total dropped: %llu\n",
(unsigned long long)total_dropped);
}
/* ── Try to (re)attach the audio FIFO without stalling video ──────────
* O_WRONLY|O_NONBLOCK returns ENXIO until ffmpeg input 1 opens the read
* end. We keep delivering video so ffmpeg progresses and opens it. */
if (audio_fifo && afd < 0) {
int fd = open(audio_fifo, O_WRONLY | O_NONBLOCK);
if (fd >= 0) {
/* Switch to blocking for coupled writes. */
int fl = fcntl(fd, F_GETFL, 0);
if (fl >= 0) fcntl(fd, F_SETFL, fl & ~O_NONBLOCK);
afd = fd;
if (!logged_aud) {
fprintf(stderr, "[fc_pipe] audio FIFO reader attached — coupled audio live\n");
logged_aud = 1;
}
}
/* else: not ready yet (ENXIO) — deliver video, retry next frame. */
}
/* ── Write VIDEO to stdout ──────────────────────────────────────────── */
if (write_all_fd(STDOUT_FILENO, ref.data, ref.size) < 0) {
if (!g_stop)
fprintf(stderr, "[fc_pipe] stdout EPIPE — ffmpeg exited\n");
break;
}
/* ── Write THIS frame's AUDIO to the FIFO, in lockstep ────────────────
* Same ring entry, same iteration ⇒ frame-coupled. The bridge writer
* guarantees every entry carries one frame-interval of audio (real
* embedded PCM, or silence when the signal has none), so ref.audio_size
* is non-zero in steady state and the audio timeline length always
* tracks the video timeline length (no drift). A 0-size entry (only at
* the very first frame, or a video-only net source) contributes nothing
* and is harmless because ffmpeg derives audio PTS from sample count. */
if (afd >= 0) {
if (ref.audio_size > 0 && ref.audio) {
if (write_all_fd(afd, ref.audio, ref.audio_size) < 0) {
fprintf(stderr, "[fc_pipe] audio FIFO EPIPE — will reattach\n");
close(afd); afd = -1;
} else {
audio_bytes += ref.audio_size;
}
} else {
audio_gaps++; /* diagnostics: frame without embedded audio */
}
}
frames_out++;
if (frames_out % 300 == 0) {
fprintf(stderr, "[fc_pipe] frames=%llu dropped=%llu audio_bytes=%llu gaps=%llu\n",
(unsigned long long)frames_out,
(unsigned long long)total_dropped,
(unsigned long long)audio_bytes,
(unsigned long long)audio_gaps);
}
}
if (afd >= 0) close(afd);
fc_consumer_close(c);
fprintf(stderr, "[fc_pipe] done frames=%llu dropped=%llu audio_bytes=%llu gaps=%llu\n",
(unsigned long long)frames_out,
(unsigned long long)total_dropped,
(unsigned long long)audio_bytes,
(unsigned long long)audio_gaps);
return 0;
}