Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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