Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions js/hang/src/catalog/audio.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { ContainerSchema, DEFAULT_CONTAINER } from "./container";
import { u53Schema } from "./integers";

// Backwards compatibility: old track schema
Expand All @@ -13,6 +14,10 @@ export const AudioConfigSchema = z.object({
// See: https://w3c.github.io/webcodecs/codec_registry.html
codec: z.string(),

// Container format for timestamp encoding
// Defaults to "legacy" when not specified in catalog (backward compatibility)
container: ContainerSchema.default(DEFAULT_CONTAINER),

// The description is used for some codecs.
// If provided, we can initialize the decoder based on the catalog alone.
// Otherwise, the initialization information is in-band.
Expand Down
18 changes: 18 additions & 0 deletions js/hang/src/catalog/container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from "zod";

/**
* Container format for frame timestamp encoding.
*
* - "legacy": Uses QUIC VarInt encoding (1-8 bytes, variable length)
* - "raw": Uses fixed u64 encoding (8 bytes, big-endian)
* - "fmp4": Fragmented MP4 container (future)
*/
export const ContainerSchema = z.enum(["legacy", "raw", "fmp4"]);

export type Container = z.infer<typeof ContainerSchema>;

/**
* Default container format when not specified.
* Set to legacy for backward compatibility.
*/
export const DEFAULT_CONTAINER: Container = "legacy";
2 changes: 2 additions & 0 deletions js/hang/src/catalog/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export * from "./audio";
export * from "./capabilities";
export * from "./chat";
export * from "./container";
export { DEFAULT_CONTAINER } from "./container";
export * from "./integers";
export * from "./location";
export * from "./preview";
Expand Down
6 changes: 5 additions & 1 deletion js/hang/src/catalog/video.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from "zod";

import { ContainerSchema, DEFAULT_CONTAINER } from "./container";
import { u53Schema } from "./integers";

// Backwards compatibility: old track schema
Expand All @@ -13,6 +13,10 @@ export const VideoConfigSchema = z.object({
// See: https://w3c.github.io/webcodecs/codec_registry.html
codec: z.string(),

// Container format for timestamp encoding
// Defaults to "legacy" when not specified in catalog (backward compatibility)
container: ContainerSchema.default(DEFAULT_CONTAINER),

// The description is used for some codecs.
// If provided, we can initialize the decoder based on the catalog alone.
// Otherwise, the initialization information is (repeated) before each key-frame.
Expand Down
155 changes: 155 additions & 0 deletions js/hang/src/container/codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type * as Catalog from "../catalog";
import { DEFAULT_CONTAINER } from "../catalog";
import type * as Time from "../time";

/**
* Encodes a timestamp according to the specified container format.
*
* @param timestamp - The timestamp in microseconds
* @param container - The container format to use
* @returns The encoded timestamp as a Uint8Array
*/
export function encodeTimestamp(timestamp: Time.Micro, container: Catalog.Container = DEFAULT_CONTAINER): Uint8Array {
switch (container) {
case "legacy":
return encodeVarInt(timestamp);
case "raw":
return encodeU64(timestamp);
case "fmp4":
throw new Error("fmp4 container not yet implemented");
}
}

/**
* Decodes a timestamp from a buffer according to the specified container format.
*
* @param buffer - The buffer containing the encoded timestamp
* @param container - The container format to use
* @returns [timestamp in microseconds, remaining buffer after timestamp]
*/
export function decodeTimestamp(
buffer: Uint8Array,
container: Catalog.Container = DEFAULT_CONTAINER,
): [Time.Micro, Uint8Array] {
switch (container) {
case "legacy": {
const [value, remaining] = decodeVarInt(buffer);
return [value as Time.Micro, remaining];
}
case "raw": {
const [value, remaining] = decodeU64(buffer);
return [value as Time.Micro, remaining];
}
case "fmp4":
throw new Error("fmp4 container not yet implemented");
}
}

/**
* Gets the size in bytes of an encoded timestamp for the given container format.
* For variable-length formats, returns the maximum size.
*
* @param container - The container format
* @returns Size in bytes
*/
export function getTimestampSize(container: Catalog.Container = DEFAULT_CONTAINER): number {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used? I don't think it's useful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. true. Removed.

switch (container) {
case "legacy":
return 8; // VarInt maximum size
case "raw":
return 8; // u64 fixed size
case "fmp4":
throw new Error("fmp4 container not yet implemented");
}
}

// ============================================================================
// LEGACY VARINT IMPLEMENTATION
// ============================================================================

const MAX_U6 = 2 ** 6 - 1;
const MAX_U14 = 2 ** 14 - 1;
const MAX_U30 = 2 ** 30 - 1;
const MAX_U53 = Number.MAX_SAFE_INTEGER;

function decodeVarInt(buf: Uint8Array): [number, Uint8Array] {
const size = 1 << ((buf[0] & 0xc0) >> 6);

const view = new DataView(buf.buffer, buf.byteOffset, size);
const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size);
let v: number;

if (size === 1) {
v = buf[0] & 0x3f;
} else if (size === 2) {
v = view.getUint16(0) & 0x3fff;
} else if (size === 4) {
v = view.getUint32(0) & 0x3fffffff;
} else if (size === 8) {
// NOTE: Precision loss above 2^52
v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn);
} else {
throw new Error("impossible");
}

return [v, remain];
}

function encodeVarInt(v: number): Uint8Array {
const dst = new Uint8Array(8);

if (v <= MAX_U6) {
dst[0] = v;
return new Uint8Array(dst.buffer, dst.byteOffset, 1);
}

if (v <= MAX_U14) {
const view = new DataView(dst.buffer, dst.byteOffset, 2);
view.setUint16(0, v | 0x4000);
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}

if (v <= MAX_U30) {
const view = new DataView(dst.buffer, dst.byteOffset, 4);
view.setUint32(0, v | 0x80000000);
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}

if (v <= MAX_U53) {
const view = new DataView(dst.buffer, dst.byteOffset, 8);
view.setBigUint64(0, BigInt(v) | 0xc000000000000000n);
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}

throw new Error(`overflow, value larger than 53-bits: ${v}`);
}

// ============================================================================
// RAW U64 IMPLEMENTATION
// ============================================================================

/**
* Decodes a fixed 8-byte big-endian unsigned 64-bit integer.
*/
function decodeU64(buf: Uint8Array): [number, Uint8Array] {
if (buf.byteLength < 8) {
throw new Error("Buffer too short for u64 decode");
}

const view = new DataView(buf.buffer, buf.byteOffset, 8);
const value = Number(view.getBigUint64(0));
const remain = new Uint8Array(buf.buffer, buf.byteOffset + 8, buf.byteLength - 8);

return [value, remain];
}

/**
* Encodes a number as a fixed 8-byte big-endian unsigned 64-bit integer.
* Much simpler than VarInt!
*/
function encodeU64(v: number): Uint8Array {
const dst = new Uint8Array(8);
const view = new DataView(dst.buffer, dst.byteOffset, 8);
view.setBigUint64(0, BigInt(v));
return dst;
}
1 change: 1 addition & 0 deletions js/hang/src/container/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./codec";
101 changes: 29 additions & 72 deletions js/hang/src/frame.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type * as Moq from "@moq/lite";
import { Effect, Signal } from "@moq/signals";
import type * as Catalog from "./catalog";
import * as Container from "./container";
import * as Time from "./time";

export interface Source {
Expand All @@ -14,33 +16,42 @@ export interface Frame {
group: number;
}

export function encode(source: Uint8Array | Source, timestamp: Time.Micro): Uint8Array {
// TODO switch over to u64 for simplicity. The varint uses 8 bytes anyway after 18 minutes lul.
// TODO Don't encode into one buffer. Write the header/payload separately to avoid reallocating.
const data = new Uint8Array(8 + (source instanceof Uint8Array ? source.byteLength : source.byteLength));
export function encode(source: Uint8Array | Source, timestamp: Time.Micro, container?: Catalog.Container): Uint8Array {
// Encode timestamp using the specified container format
const timestampBytes = Container.encodeTimestamp(timestamp, container);

const size = setVint53(data, timestamp).byteLength;
// Allocate buffer for timestamp + payload
const payloadSize = source instanceof Uint8Array ? source.byteLength : source.byteLength;
const data = new Uint8Array(timestampBytes.byteLength + payloadSize);

// Write timestamp header
data.set(timestampBytes, 0);

// Write payload
if (source instanceof Uint8Array) {
data.set(source, size);
data.set(source, timestampBytes.byteLength);
} else {
source.copyTo(data.subarray(size));
source.copyTo(data.subarray(timestampBytes.byteLength));
}
return data.subarray(0, (source instanceof Uint8Array ? source.byteLength : source.byteLength) + size);

return data;
}

// NOTE: A keyframe is always the first frame in a group, so it's not encoded on the wire.
export function decode(buffer: Uint8Array): { data: Uint8Array; timestamp: Time.Micro } {
const [us, data] = getVint53(buffer);
const timestamp = us as Time.Micro;
return { timestamp, data };
export function decode(buffer: Uint8Array, container?: Catalog.Container): { data: Uint8Array; timestamp: Time.Micro } {
// Decode timestamp using the specified container format
const [timestamp, data] = Container.decodeTimestamp(buffer, container);
return { timestamp: timestamp as Time.Micro, data };
}

export class Producer {
#track: Moq.Track;
#group?: Moq.Group;
#container?: Catalog.Container;

constructor(track: Moq.Track) {
constructor(track: Moq.Track, container?: Catalog.Container) {
this.#track = track;
this.#container = container;
}

encode(data: Uint8Array | Source, timestamp: Time.Micro, keyframe: boolean) {
Expand All @@ -51,7 +62,7 @@ export class Producer {
throw new Error("must start with a keyframe");
}

this.#group?.writeFrame(encode(data, timestamp));
this.#group?.writeFrame(encode(data, timestamp, this.#container));
}

close() {
Expand All @@ -63,6 +74,7 @@ export class Producer {
export interface ConsumerProps {
// Target latency in milliseconds (default: 0)
latency?: Signal<Time.Milli> | Time.Milli;
container?: Catalog.Container;
}

interface Group {
Expand All @@ -74,6 +86,7 @@ interface Group {
export class Consumer {
#track: Moq.Track;
#latency: Signal<Time.Milli>;
#container?: Catalog.Container;
#groups: Group[] = [];
#active?: number; // the active group sequence number

Expand All @@ -85,6 +98,7 @@ export class Consumer {
constructor(track: Moq.Track, props?: ConsumerProps) {
this.#track = track;
this.#latency = Signal.from(props?.latency ?? Time.Milli.zero);
this.#container = props?.container;

this.#signals.spawn(this.#run.bind(this));
this.#signals.cleanup(() => {
Expand Down Expand Up @@ -138,7 +152,7 @@ export class Consumer {
const next = await group.consumer.readFrame();
if (!next) break;

const { data, timestamp } = decode(next);
const { data, timestamp } = decode(next, this.#container);
const frame = {
data,
timestamp,
Expand Down Expand Up @@ -268,60 +282,3 @@ export class Consumer {
this.#groups.length = 0;
}
}

const MAX_U6 = 2 ** 6 - 1;
const MAX_U14 = 2 ** 14 - 1;
const MAX_U30 = 2 ** 30 - 1;
const MAX_U53 = Number.MAX_SAFE_INTEGER;
//const MAX_U62: bigint = 2n ** 62n - 1n;

// QUIC VarInt
function getVint53(buf: Uint8Array): [number, Uint8Array] {
const size = 1 << ((buf[0] & 0xc0) >> 6);

const view = new DataView(buf.buffer, buf.byteOffset, size);
const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size);
let v: number;

if (size === 1) {
v = buf[0] & 0x3f;
} else if (size === 2) {
v = view.getUint16(0) & 0x3fff;
} else if (size === 4) {
v = view.getUint32(0) & 0x3fffffff;
} else if (size === 8) {
// NOTE: Precision loss above 2^52
v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn);
} else {
throw new Error("impossible");
}

return [v, remain];
}

function setVint53(dst: Uint8Array, v: number): Uint8Array {
if (v <= MAX_U6) {
dst[0] = v;
return new Uint8Array(dst.buffer, dst.byteOffset, 1);
}

if (v <= MAX_U14) {
const view = new DataView(dst.buffer, dst.byteOffset, 2);
view.setUint16(0, v | 0x4000);
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}

if (v <= MAX_U30) {
const view = new DataView(dst.buffer, dst.byteOffset, 4);
view.setUint32(0, v | 0x80000000);
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}

if (v <= MAX_U53) {
const view = new DataView(dst.buffer, dst.byteOffset, 8);
view.setBigUint64(0, BigInt(v) | 0xc000000000000000n);
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}

throw new Error(`overflow, value larger than 53-bits: ${v}`);
}
Loading
Loading