diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index a491cde3189..ba4e67040b3 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -101,7 +101,8 @@ inside. The header is the reference: each function carries a doc comment. - **Zeroed config is the default.** A zeroed `moq_client_config` means the default for every field, so new fields don't break callers. A field whose default isn't zero has a `has_*` flag: `backoff_timeout_us = 0` means "retry forever" only with `has_backoff_timeout = true`. - **Compressed tracks.** `moq_track_info.compress` on a raw track sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. - **fMP4 import.** `MOQ_CONTAINER_FORMAT_FMP4` splits each fragment into one frame per sample. -- **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `moq_publish_media_cut`: after every frame, or at a segment cadence to align with video. +- **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `moq_publish_media_cut`: after every frame, or at a segment cadence to align with video. Pass that cadence as `moq_audio_init.hint.group_duration_us`. +- **Catalog hints.** Give the catalog what the encoder knows instead of having it estimated: `moq_video_init.hint` (coded size, bitrate, frame rate, group duration) and `moq_audio_init.hint` (bitrate, group duration). A supplied bitrate is never re-estimated. `group_duration_us` is the longest span between two group starts: the GOP, or your cut cadence. Leave a field's `has_*` flag false to have it measured. A bitrate or `group_duration_us` flag set with a 0 value returns `MOQ_ERROR_INVALID_CONFIG`. - **Ending cleanly.** When the input ends, finish every track, then call `moq_publish_catalog_finish()` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `moq_publish_close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close ends with `UNROUTABLE`, which readers take as the publisher leaving, and its handle is released. - **Timeline breaks.** On a seek or pause, call `moq_publish_media_discontinuity`, then keep timestamps moving forward and resume video on a keyframe. - **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's `timestamp_us`, rounded down to a whole microsecond; `timestamp_present` is false on an untimed track. A snapshot consumer's callback fires for each state in order, but a group superseded before it is read is skipped. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index cf5e9a123f2..b13648e59f6 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -176,6 +176,76 @@ pub struct moq_audio_init { pub label: *const c_char, /// Length of `label` in bytes. pub label_len: usize, + + /// Catalog fields the init bytes cannot reveal. Zeroed means none. + pub hint: moq_audio_hint, +} + +/// Optional catalog fields for [moq_audio_init::hint]. +/// +/// Zero the struct and set only the `has_*` flags you want. A supplied field is +/// published as given and is never re-estimated. An absent one is measured from +/// the frames, as without a hint. A flag set with a 0 value is an invalid config. +/// +/// New optional fields are appended so existing initializers keep their meaning. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct moq_audio_hint { + /// Maximum bitrate in bits per second when `has_bitrate` is true. + pub bitrate: u64, + /// Whether `bitrate` is present. + pub has_bitrate: bool, + + /// The longest span between two group starts on this track, in + /// microseconds, when `has_group_duration` is true: a video GOP, or the span + /// between the audio cuts you make. An upper bound, so a broadcast may take + /// the largest across its tracks. Must be at least 1 and at most + /// 2^53 - 1 milliseconds. + pub group_duration_us: u64, + /// Whether `group_duration_us` is present. + pub has_group_duration: bool, +} + +impl moq_audio_hint { + /// Copy the supplied fields onto the importer's init. + fn apply(&self, init: &mut moq_mux::import::AudioInit) -> Result<(), Error> { + init.bitrate = supplied_bitrate(self.has_bitrate, self.bitrate)?; + Ok(()) + } + + /// The supplied group duration, for the track's HLS records. + fn group_duration(&self) -> Result, Error> { + group_duration(self.has_group_duration, self.group_duration_us) + } +} + +/// The longest group duration a hint may give: 2^53 - 1 milliseconds, the largest +/// integer a JavaScript reader of the catalog holds exactly. +const MAX_GROUP_DURATION_US: u64 = ((1 << 53) - 1) * 1000; + +/// A supplied group duration, `None` when absent, or an error for 0 or past +/// [MAX_GROUP_DURATION_US]. +fn group_duration(present: bool, us: u64) -> Result, Error> { + if !present { + return Ok(None); + } + if us == 0 || us > MAX_GROUP_DURATION_US { + return Err(Error::InvalidConfig(format!( + "group_duration_us must be 1..={MAX_GROUP_DURATION_US}, got {us}" + ))); + } + Ok(Some(std::time::Duration::from_micros(us))) +} + +/// A supplied bitrate, `None` when absent, or an error for 0. +fn supplied_bitrate(present: bool, bitrate: u64) -> Result, Error> { + match (present, bitrate) { + (false, _) => Ok(None), + (true, 0) => Err(Error::InvalidConfig( + "bitrate must not be 0 when has_bitrate is set".into(), + )), + (true, bitrate) => Ok(Some(bitrate)), + } } /// Configuration for [moq_publish_video]. @@ -218,7 +288,7 @@ pub struct moq_video_hint { /// Whether `coded_width` and `coded_height` are present. pub has_coded: bool, - /// Maximum bitrate in bits per second when `has_bitrate` is true. + /// Maximum bitrate in bits per second when `has_bitrate` is true; it must not be 0. pub bitrate: u64, /// Whether `bitrate` is present. pub has_bitrate: bool, @@ -232,26 +302,38 @@ pub struct moq_video_hint { pub optimize_for_latency: bool, /// Whether `optimize_for_latency` is present. pub has_optimize_for_latency: bool, + + /// The longest span between two group starts on this track, in + /// microseconds, when `has_group_duration` is true: a video GOP, or the span + /// between the audio cuts you make. An upper bound, so a broadcast may take + /// the largest across its tracks. Must be at least 1 and at most + /// 2^53 - 1 milliseconds. + pub group_duration_us: u64, + /// Whether `group_duration_us` is present. + pub has_group_duration: bool, } impl moq_video_hint { - /// The catalog hint these flags describe. - fn resolve(&self) -> moq_mux::catalog::VideoHint { + /// The catalog hint these flags describe, or an error for a bitrate flag set with 0. + fn resolve(&self) -> Result { let mut out = moq_mux::catalog::VideoHint::default(); if self.has_coded { out.coded_width = Some(self.coded_width); out.coded_height = Some(self.coded_height); } - if self.has_bitrate { - out.bitrate = Some(self.bitrate); - } + out.bitrate = supplied_bitrate(self.has_bitrate, self.bitrate)?; if self.has_framerate { out.framerate = Some(self.framerate); } if self.has_optimize_for_latency { out.optimize_for_latency = Some(self.optimize_for_latency); } - out + Ok(out) + } + + /// The supplied group duration, for the track's HLS records. + fn group_duration(&self) -> Result, Error> { + group_duration(self.has_group_duration, self.group_duration_us) } } @@ -2308,21 +2390,26 @@ pub extern "C" fn moq_publish_catalog_finish(broadcast: u32) -> i32 { pub unsafe extern "C" fn moq_publish_audio(broadcast: u32, config: *const moq_audio_init) -> i32 { ffi::enter(move || { let broadcast = ffi::parse_id(broadcast)?; - let audio = unsafe { parse_audio_init(config)? }; - State::lock().publish.audio(broadcast, audio) + let (audio, hls) = unsafe { parse_audio_init(config)? }; + State::lock().publish.audio(broadcast, audio, hls) }) } +/// Returns the init and the track's group duration hint. +/// /// # Safety /// - As [moq_publish_audio], for `config`. -unsafe fn parse_audio_init(config: *const moq_audio_init) -> Result { +unsafe fn parse_audio_init( + config: *const moq_audio_init, +) -> Result<(moq_mux::import::AudioInit, Option), Error> { let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; let init = unsafe { ffi::parse_slice(config.init, config.init_len)? }; let label = unsafe { ffi::parse_str_optional(config.label, config.label_len)? }; let mut audio = moq_mux::import::AudioInit::new(audio_format_from_u32(config.format)?, init.to_vec()); audio.label = label.map(str::to_string); - Ok(audio) + config.hint.apply(&mut audio)?; + Ok((audio, config.hint.group_duration()?)) } /// Publish one video codec as a new media track. @@ -2338,22 +2425,26 @@ unsafe fn parse_audio_init(config: *const moq_audio_init) -> Result i32 { ffi::enter(move || { let broadcast = ffi::parse_id(broadcast)?; - let video = unsafe { parse_video_init(config)? }; - State::lock().publish.video(broadcast, video) + let (video, hls) = unsafe { parse_video_init(config)? }; + State::lock().publish.video(broadcast, video, hls) }) } +/// Returns the init and the track's group duration hint. +/// /// # Safety /// - As [moq_publish_audio], for a [moq_video_init]. -unsafe fn parse_video_init(config: *const moq_video_init) -> Result { +unsafe fn parse_video_init( + config: *const moq_video_init, +) -> Result<(moq_mux::import::VideoInit, Option), Error> { let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; let init = unsafe { ffi::parse_slice(config.init, config.init_len)? }; let label = unsafe { ffi::parse_str_optional(config.label, config.label_len)? }; let mut video = moq_mux::import::VideoInit::new(video_format_from_u32(config.format)?, init.to_vec()); video.label = label.map(str::to_string); - video.hint = config.hint.resolve(); - Ok(video) + video.hint = config.hint.resolve()?; + Ok((video, config.hint.group_duration()?)) } /// Publish a container, which demuxes and publishes its own tracks. @@ -3134,8 +3225,8 @@ pub unsafe extern "C" fn moq_track_request_accept(request: u32, info: *const moq pub unsafe extern "C" fn moq_track_request_audio(request: u32, config: *const moq_audio_init) -> i32 { ffi::enter(move || { let request = ffi::parse_id(request)?; - let audio = unsafe { parse_audio_init(config)? }; - State::lock().publish.track_request_audio(request, audio) + let (audio, hls) = unsafe { parse_audio_init(config)? }; + State::lock().publish.track_request_audio(request, audio, hls) }) } @@ -3150,8 +3241,8 @@ pub unsafe extern "C" fn moq_track_request_audio(request: u32, config: *const mo pub unsafe extern "C" fn moq_track_request_video(request: u32, config: *const moq_video_init) -> i32 { ffi::enter(move || { let request = ffi::parse_id(request)?; - let video = unsafe { parse_video_init(config)? }; - State::lock().publish.track_request_video(request, video) + let (video, hls) = unsafe { parse_video_init(config)? }; + State::lock().publish.track_request_video(request, video, hls) }) } diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index ca2c3cae7e6..611ec466e14 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -508,7 +508,15 @@ impl Publish { children.insert(&mut self.media, media, Child::Media) } - pub fn audio(&mut self, broadcast: Id, init: import::AudioInit) -> Result { + /// `hls` is the longest span between the track's group starts, when known (see [`Reserved::with_hls_hint`]). + /// + /// [`Reserved::with_hls_hint`]: moq_mux::catalog::Reserved::with_hls_hint + pub fn audio( + &mut self, + broadcast: Id, + init: import::AudioInit, + hls: Option, + ) -> Result { let Broadcast { producer: broadcast, catalog, @@ -519,12 +527,18 @@ impl Publish { let name = broadcast.unique_name(&format!(".{}", init.format)); let request = broadcast.reserve_track(name)?; - let track = import::Track::audio(request, catalog.reserve(), init)?; + let track = import::Track::audio(request, catalog.reserve().with_hls_hint(hls), init)?; let children = children.clone(); self.insert_media(track, children) } - pub fn video(&mut self, broadcast: Id, init: import::VideoInit) -> Result { + /// `hls` as in [`Self::audio`]. + pub fn video( + &mut self, + broadcast: Id, + init: import::VideoInit, + hls: Option, + ) -> Result { let Broadcast { producer: broadcast, catalog, @@ -535,7 +549,7 @@ impl Publish { let name = broadcast.unique_name(&format!(".{}", init.format)); let request = broadcast.reserve_track(name)?; - let track = import::Track::video(request, catalog.reserve(), init)?; + let track = import::Track::video(request, catalog.reserve().with_hls_hint(hls), init)?; let children = children.clone(); self.insert_media(track, children) } @@ -998,26 +1012,36 @@ impl Publish { } /// Accept a track request as an audio track, returning a media handle like [`Self::audio`]. - pub fn track_request_audio(&mut self, request: Id, init: import::AudioInit) -> Result { + pub fn track_request_audio( + &mut self, + request: Id, + init: import::AudioInit, + hls: Option, + ) -> Result { let TrackRequest { broadcast, children, request, } = self.track_request.remove(request).ok_or(Error::NotFound)?; let catalog = self.catalog(broadcast)?; - let track = import::Track::audio(request, catalog.reserve(), init)?; + let track = import::Track::audio(request, catalog.reserve().with_hls_hint(hls), init)?; self.insert_media(track, children) } /// Accept a track request as a video track, returning a media handle like [`Self::video`]. - pub fn track_request_video(&mut self, request: Id, init: import::VideoInit) -> Result { + pub fn track_request_video( + &mut self, + request: Id, + init: import::VideoInit, + hls: Option, + ) -> Result { let TrackRequest { broadcast, children, request, } = self.track_request.remove(request).ok_or(Error::NotFound)?; let catalog = self.catalog(broadcast)?; - let track = import::Track::video(request, catalog.reserve(), init)?; + let track = import::Track::video(request, catalog.reserve().with_hls_hint(hls), init)?; self.insert_media(track, children) } @@ -1377,7 +1401,7 @@ mod children_tests { head.extend_from_slice(&[1, 2, 0, 0, 0x80, 0xbb, 0, 0, 0, 0, 0]); let media = state .publish - .audio(broadcast, import::AudioInit::new(import::AudioFormat::Opus, head)) + .audio(broadcast, import::AudioInit::new(import::AudioFormat::Opus, head), None) .unwrap(); let name = state.publish.media_demand(media).unwrap().name().to_string(); let readers = [ diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index ec7ea65798d..83606a3a35c 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -374,6 +374,7 @@ fn publish_audio(broadcast: u32, format: moq_audio_format, init: &[u8], label: O init_len: init.len(), label, label_len, + hint: moq_audio_hint::default(), }; unsafe { moq_publish_audio(broadcast, &config) } @@ -2671,6 +2672,7 @@ fn unknown_format() { init_len: 0, label: std::ptr::null(), label_len: 0, + hint: moq_audio_hint::default(), }; let ret = unsafe { moq_publish_audio(broadcast, &config) }; assert!(ret < 0, "an out-of-range format code should fail"); @@ -3445,6 +3447,7 @@ fn dynamic_track_request_publishes_media() { init_len: init.len(), label: std::ptr::null(), label_len: 0, + hint: moq_audio_hint::default(), }; let media = id(unsafe { moq_track_request_audio(request, &config) }); @@ -6642,3 +6645,363 @@ unsafe fn encoded_frame(id: u32, dst: *mut moq_frame) -> i32 { } status } + +/// An audio rendition as the publisher currently lists it. +fn audio_rendition(broadcast: u32, name: &str) -> hang::catalog::AudioConfig { + published_catalog(broadcast).audio.renditions[name].clone() +} + +/// Publish `count` 20 ms Opus packets of `size` bytes from `start_us`, one group each. +fn write_opus(media: u32, start_us: u64, count: u64, size: usize) { + let payload = vec![0u8; size]; + for i in 0..count { + let ts = start_us + i * 20_000; + assert_eq!( + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), ts) }, + 0 + ); + assert_eq!(moq_publish_media_cut(media), 0); + } +} + +/// A supplied audio bitrate is published as given and never estimated: writes that measure a +/// different one leave the rendition unchanged, while an unhinted track still measures its own. +#[test] +fn audio_hint_is_authoritative() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"audio-hint"); + let init = opus_head(); + + let free = id(publish_audio( + broadcast, + moq_audio_format::MOQ_AUDIO_FORMAT_OPUS, + &init, + None, + )); + let before: Vec = published_catalog(broadcast).audio.renditions.keys().cloned().collect(); + + let config = moq_audio_init { + format: moq_audio_format::MOQ_AUDIO_FORMAT_OPUS as u32, + init: init.as_ptr(), + init_len: init.len(), + label: std::ptr::null(), + label_len: 0, + hint: moq_audio_hint { + bitrate: 64_000, + has_bitrate: true, + ..moq_audio_hint::default() + }, + }; + let hinted = id(unsafe { moq_publish_audio(broadcast, &config) }); + let name = (published_catalog(broadcast).audio.renditions.keys()) + .find(|name| !before.contains(name)) + .expect("hinted rendition listed") + .clone(); + + assert_eq!(audio_rendition(broadcast, &name).bitrate, Some(64_000)); + + // 1000-byte packets every 20 ms measure 400 kb/s. + write_opus(free, 0, 75, 1000); + write_opus(hinted, 0, 75, 1000); + + assert_eq!( + audio_rendition(broadcast, &name).bitrate, + Some(64_000), + "a supplied bitrate is never re-estimated" + ); + assert_eq!( + audio_rendition(broadcast, &before[0]).bitrate, + Some(400_000), + "an unhinted track still measures its bitrate" + ); + + assert_eq!(moq_publish_media_finish(hinted), 0); + assert_eq!(moq_publish_media_finish(free), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// A zeroed audio hint behaves as before: the bitrate is measured. +#[test] +fn zeroed_audio_hint_measures() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"audio-hint-zeroed"); + let media = id(publish_audio( + broadcast, + moq_audio_format::MOQ_AUDIO_FORMAT_OPUS, + &opus_head(), + None, + )); + let name = published_catalog(broadcast) + .audio + .renditions + .keys() + .next() + .unwrap() + .clone(); + assert_eq!(audio_rendition(broadcast, &name).bitrate, None); + + write_opus(media, 0, 75, 1000); + assert_eq!(audio_rendition(broadcast, &name).bitrate, Some(400_000)); + + assert_eq!(moq_publish_media_finish(media), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// A video group duration hint declares the HLS duration up front, as the smallest multiple of the +/// hint at or above the grid. +#[test] +fn video_group_duration_hint_declares_hls() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"video-group-duration"); + let init = h264_init(); + let config = moq_video_init { + format: moq_video_format::MOQ_VIDEO_FORMAT_AVC3 as u32, + init: init.as_ptr(), + init_len: init.len(), + label: std::ptr::null(), + label_len: 0, + hint: moq_video_hint { + group_duration_us: 3_000_000, + has_group_duration: true, + ..moq_video_hint::default() + }, + }; + let media = id(unsafe { moq_publish_video(broadcast, &config) }); + + let catalog = published_catalog(broadcast); + let rendition = catalog.video.renditions.values().next().expect("rendition listed"); + let hls = rendition.hls.clone().expect("records listed before any frame"); + assert_eq!(hls.duration, std::time::Duration::from_secs(6)); + + assert_eq!(moq_publish_media_finish(media), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// Hints reach a track published for a request, as for moq_publish_audio and moq_publish_video. +#[test] +fn track_request_media_carries_hints() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"dynamic-hints"); + let request_cb = Callback::new(); + let _dynamic = id(unsafe { moq_publish_requests(broadcast, Some(channel_callback), request_cb.ptr) }); + let consume = consume_local(broadcast); + + let audio_cb = Callback::new(); + let audio_consumer = consume_track(consume, b"audio", &audio_cb); + let request = id(request_cb.recv()); + let opus = opus_head(); + let audio_config = moq_audio_init { + format: moq_audio_format::MOQ_AUDIO_FORMAT_OPUS as u32, + init: opus.as_ptr(), + init_len: opus.len(), + label: std::ptr::null(), + label_len: 0, + hint: moq_audio_hint { + bitrate: 96_000, + has_bitrate: true, + group_duration_us: 100_000, + has_group_duration: true, + }, + }; + let audio = id(unsafe { moq_track_request_audio(request, &audio_config) }); + + let video_cb = Callback::new(); + let video_consumer = consume_track(consume, b"video", &video_cb); + let request = id(request_cb.recv()); + let h264 = h264_init(); + let video_config = moq_video_init { + format: moq_video_format::MOQ_VIDEO_FORMAT_AVC3 as u32, + init: h264.as_ptr(), + init_len: h264.len(), + label: std::ptr::null(), + label_len: 0, + hint: moq_video_hint { + bitrate: 2_000_000, + has_bitrate: true, + group_duration_us: 3_000_000, + has_group_duration: true, + ..moq_video_hint::default() + }, + }; + let video = id(unsafe { moq_track_request_video(request, &video_config) }); + + let catalog = published_catalog(broadcast); + let audio_rendition = catalog.audio.renditions.values().next().expect("audio listed"); + assert_eq!(audio_rendition.bitrate, Some(96_000)); + assert_eq!( + audio_rendition.hls.as_ref().map(|hls| hls.duration), + Some(std::time::Duration::from_secs(4)) + ); + let video_rendition = catalog.video.renditions.values().next().expect("video listed"); + assert_eq!(video_rendition.bitrate, Some(2_000_000)); + assert_eq!( + video_rendition.hls.as_ref().map(|hls| hls.duration), + Some(std::time::Duration::from_secs(6)) + ); + + assert_eq!(moq_consume_track_cancel(audio_consumer), 0); + assert_eq!(audio_cb.recv_terminal(), 0); + assert_eq!(moq_consume_track_cancel(video_consumer), 0); + assert_eq!(video_cb.recv_terminal(), 0); + assert_eq!(moq_publish_media_finish(audio), 0); + assert_eq!(moq_publish_media_finish(video), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(request_cb.recv_terminal(), 0); + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// A hint flag set with a 0 value, or a group duration past what milliseconds hold for a +/// JavaScript reader, is an invalid config rather than a second encoding of "absent". +#[test] +fn invalid_hints_are_refused() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"invalid-hints"); + let opus = opus_head(); + let h264 = h264_init(); + let audio = |hint: moq_audio_hint| { + let config = moq_audio_init { + format: moq_audio_format::MOQ_AUDIO_FORMAT_OPUS as u32, + init: opus.as_ptr(), + init_len: opus.len(), + label: std::ptr::null(), + label_len: 0, + hint, + }; + unsafe { moq_publish_audio(broadcast, &config) } + }; + let video = |hint: moq_video_hint| { + let config = moq_video_init { + format: moq_video_format::MOQ_VIDEO_FORMAT_AVC3 as u32, + init: h264.as_ptr(), + init_len: h264.len(), + label: std::ptr::null(), + label_len: 0, + hint, + }; + unsafe { moq_publish_video(broadcast, &config) } + }; + + let invalid = MOQ_ERROR_INVALID_CONFIG; + assert_eq!( + audio(moq_audio_hint { + has_bitrate: true, + ..moq_audio_hint::default() + }), + invalid, + "bitrate 0" + ); + assert_eq!( + audio(moq_audio_hint { + has_group_duration: true, + ..moq_audio_hint::default() + }), + invalid, + "audio group duration 0" + ); + assert_eq!( + audio(moq_audio_hint { + group_duration_us: u64::MAX, + has_group_duration: true, + ..moq_audio_hint::default() + }), + invalid, + "audio group duration past milliseconds" + ); + assert_eq!( + video(moq_video_hint { + has_group_duration: true, + ..moq_video_hint::default() + }), + invalid, + "video group duration 0" + ); + assert_eq!( + video(moq_video_hint { + has_bitrate: true, + ..moq_video_hint::default() + }), + invalid, + "video bitrate 0" + ); + + let catalog = published_catalog(broadcast); + assert!(catalog.audio.renditions.is_empty(), "no audio published"); + assert!(catalog.video.renditions.is_empty(), "no video published"); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +/// A group duration hint lists the track's HLS records in the first catalog, so the first group +/// doesn't republish it; without one they arrive once a group has been written. +#[test] +fn group_duration_hint_lists_hls_up_front() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"group-duration"); + let init = opus_head(); + + let free = id(publish_audio( + broadcast, + moq_audio_format::MOQ_AUDIO_FORMAT_OPUS, + &init, + None, + )); + let free_name = published_catalog(broadcast) + .audio + .renditions + .keys() + .next() + .unwrap() + .clone(); + assert!( + audio_rendition(broadcast, &free_name).hls.is_none(), + "unhinted: no records yet" + ); + + let config = moq_audio_init { + format: moq_audio_format::MOQ_AUDIO_FORMAT_OPUS as u32, + init: init.as_ptr(), + init_len: init.len(), + label: std::ptr::null(), + label_len: 0, + hint: moq_audio_hint { + group_duration_us: 100_000, + has_group_duration: true, + ..moq_audio_hint::default() + }, + }; + let hinted = id(unsafe { moq_publish_audio(broadcast, &config) }); + let name = (published_catalog(broadcast).audio.renditions.keys()) + .find(|name| **name != free_name) + .expect("hinted rendition listed") + .clone(); + let listed = audio_rendition(broadcast, &name); + let hls = listed.hls.clone().expect("hinted: records listed before any frame"); + // The smallest multiple of the 100 ms hint at or above the 4 s grid. + assert_eq!(hls.duration, std::time::Duration::from_secs(4)); + + // 100 ms groups of five 20 ms packets. + let payload = [0u8; 100]; + for i in 0..20u64 { + assert_eq!( + unsafe { moq_publish_media_frame(hinted, payload.as_ptr(), payload.len(), i * 20_000) }, + 0 + ); + if i % 5 == 4 { + assert_eq!(moq_publish_media_cut(hinted), 0); + } + } + assert_eq!( + audio_rendition(broadcast, &name).hls, + listed.hls, + "the first groups don't change the listed records" + ); + + assert_eq!(moq_publish_media_finish(hinted), 0); + assert_eq!(moq_publish_media_finish(free), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 95b1a8cf1df..3ae2363e3d8 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -176,7 +176,8 @@ impl RenditionConfig for hang::catalog::BinaryConfig { /// /// Video importers take one because they build the [`VideoConfig`](hang::catalog::VideoConfig) /// themselves, out of the bitstream, so the caller never holds the config to set these on. An audio -/// importer publishes a complete config from its init bytes, so audio has no equivalent. +/// importer publishes a complete config from its init bytes, so the field those bytes can't carry +/// (bitrate) rides on [`AudioInit`](crate::import::AudioInit) instead. #[derive(Clone, Default, Debug, PartialEq)] #[non_exhaustive] pub struct VideoHint { diff --git a/rs/moq-mux/src/import/init.rs b/rs/moq-mux/src/import/init.rs index 954460340c2..9caec7d87e4 100644 --- a/rs/moq-mux/src/import/init.rs +++ b/rs/moq-mux/src/import/init.rs @@ -22,6 +22,9 @@ pub struct AudioInit { /// [`Legacy`](hang::catalog::Container::Legacy) unless selected. A video rendition carries the /// same knob on its [`VideoInit::hint`]. pub container: hang::catalog::Container, + /// The maximum bitrate in bits per second, for an encoder that knows it. Supplied, it replaces + /// any value the init bytes give and is never estimated. + pub bitrate: Option, } impl AudioInit { @@ -32,6 +35,7 @@ impl AudioInit { data: data.into(), label: None, container: hang::catalog::Container::default(), + bitrate: None, } } } diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 227d8fe09e0..3f80da6fb7e 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -40,11 +40,15 @@ fn track_info( } } -/// The codec parser fills everything from the init bytes, so the label and container are all the -/// caller adds. +/// The codec parser fills the decoder fields from the init bytes; the caller adds the label, the +/// container, and the bitrate when it knows it, which then counts as supplied and replaces any the +/// init bytes give. fn with_init(init: &AudioInit, mut config: hang::catalog::AudioConfig) -> hang::catalog::AudioConfig { config.label = init.label.clone(); config.container = init.container.clone(); + if init.bitrate.is_some() { + config.bitrate = init.bitrate; + } config }