Skip to content
Draft
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
30 changes: 20 additions & 10 deletions lib/contribute/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,30 @@ export class Encoder {
}

#start(controller: TransformStreamDefaultController<AudioDecoderConfig | EncodedAudioChunk>) {
this.#encoder = new AudioEncoder({
output: (frame, metadata) => {
this.#enqueue(controller, frame, metadata)
},
error: (err) => {
throw err
},
})
try {
this.#encoder = new AudioEncoder({
output: (frame, metadata) => {
this.#enqueue(controller, frame, metadata)
},
error: (err) => {
throw err
},
})

this.#encoder.configure(this.#encoderConfig)
this.#encoder.configure(this.#encoderConfig)
} catch (e) {
console.error("Failed to configure AudioEncoder:", e)
throw e
}
}

#transform(frame: AudioData) {
this.#encoder.encode(frame)
try {
this.#encoder.encode(frame)
} catch (e) {
console.error("Failed to encode audio frame:", e)
throw e
}
frame.close()
}

Expand Down
4 changes: 3 additions & 1 deletion lib/contribute/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class Broadcast {
name: `${track.name}.m4s`,
initTrack: `${track.name}.mp4`,
selectionParams: {
mimeType: "audio/ogg",
mimeType: "audio/mp4",
codec: config.audio.codec,
samplerate: settings.sampleRate,
//sampleSize: settings.sampleSize,
Expand Down Expand Up @@ -104,6 +104,7 @@ export class Broadcast {
}

async #run() {
console.log("[Broadcast] #run loop started")
await this.connection.announce(this.namespace)

for (;;) {
Expand All @@ -119,6 +120,7 @@ export class Broadcast {
}

async #serveSubscribe(subscriber: SubscribeRecv) {
console.log(`[Broadcast] #serveSubscribe for: ${subscriber.track}`)
try {
const [base, ext] = splitExt(subscriber.track)
if (ext === "catalog") {
Expand Down
15 changes: 10 additions & 5 deletions lib/contribute/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ export class Container {

this.encode = new TransformStream({
transform: (frame, controller) => {
if (isDecoderConfig(frame)) {
return this.#init(frame, controller)
} else {
return this.#enqueue(frame, controller)
try {
if (isDecoderConfig(frame)) {
console.log("Container received decoder config:", frame)
return this.#init(frame, controller)
} else {
return this.#enqueue(frame, controller)
}
} catch (e) {
console.error("Container failed to process frame:", e)
throw e
}
},
})
Expand Down Expand Up @@ -64,7 +70,6 @@ export class Container {
const data = new MP4.Stream(desc, 8, MP4.Stream.LITTLE_ENDIAN)
dops.parse(data)

dops.Version = 0
options.description = dops
options.hdlr = "soun"
} else {
Expand Down
21 changes: 20 additions & 1 deletion lib/contribute/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export class Track {
constructor(media: MediaStreamTrack, config: BroadcastConfig) {
this.name = media.kind

console.log(`[Track] constructor for: ${this.name}`)

// We need to split based on type because Typescript is hard
if (isAudioTrack(media)) {
if (!config.audio) throw new Error("no audio config")
Expand All @@ -34,6 +36,8 @@ export class Track {
}

async #runAudio(track: MediaStreamAudioTrack, config: AudioEncoderConfig) {
console.log(`[Track] #runAudio for: ${this.name}`)

const source = new MediaStreamTrackProcessor({ track })
const encoder = new Audio.Encoder(config)
const container = new Container()
Expand All @@ -45,10 +49,19 @@ export class Track {
abort: (e) => this.#close(e),
})

return source.readable.pipeThrough(encoder.frames).pipeThrough(container.encode).pipeTo(segments)
return source.readable
.pipeThrough(encoder.frames)
.pipeThrough(container.encode)
.pipeTo(segments)
.catch((err) => {
console.error("Audio pipeline error:", err)
throw err
})
}

async #runVideo(track: MediaStreamVideoTrack, config: VideoEncoderConfig) {
console.log(`[Track] #runVideo for: ${this.name}`)

const source = new MediaStreamTrackProcessor({ track })
const encoder = new Video.Encoder(config)
const container = new Container()
Expand All @@ -64,6 +77,8 @@ export class Track {
}

async #write(chunk: Chunk) {
console.log(`[Track: ${this.name}] #write received chunk of type: ${chunk.type}`)

if (chunk.type === "init") {
this.#init = chunk.data
this.#notify.wake()
Expand All @@ -72,6 +87,8 @@ export class Track {

let current = this.#segments.at(-1)
if (!current || chunk.type === "key") {
console.log(`[Track: ${this.name}] Keyframe received or first segment. Creating new segment.`)

if (current) {
await current.input.close()
}
Expand Down Expand Up @@ -99,6 +116,7 @@ export class Track {
const writer = current.input.getWriter()

if ((writer.desiredSize || 0) > 0) {
console.log(`[Track: ${this.name}] Writing chunk to segment ${current.id}`)
await writer.write(chunk)
} else {
console.warn("dropping chunk", writer.desiredSize)
Expand All @@ -112,6 +130,7 @@ export class Track {

const current = this.#segments.at(-1)
if (current) {
console.log(`[Track: ${this.name}] Closing segment ${current.id}`)
await current.input.close()
}

Expand Down
10 changes: 7 additions & 3 deletions lib/media/mp4/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export function isVideoTrack(track: MP4.Track): track is MP4.VideoTrack {

// TODO contribute to mp4box
MP4.BoxParser.dOpsBox.prototype.write = function (stream: MP4.Stream) {
this.size = this.ChannelMappingFamily === 0 ? 11 : 13 + this.ChannelMapping!.length
this.size = 11 // Base size for dOps box
if (this.ChannelMappingFamily !== 0) {
this.size += 2 + this.ChannelMapping!.length
}

this.writeHeader(stream)

stream.writeUint8(this.Version)
Expand All @@ -27,9 +31,9 @@ MP4.BoxParser.dOpsBox.prototype.write = function (stream: MP4.Stream) {
stream.writeInt16(this.OutputGain)
stream.writeUint8(this.ChannelMappingFamily)

if (!this.StreamCount || !this.CoupledCount) throw new Error("failed to write dOps box")

if (this.ChannelMappingFamily !== 0) {
if (!this.StreamCount || !this.CoupledCount) throw new Error("failed to write dOps box with channel mapping")

stream.writeUint8(this.StreamCount)
stream.writeUint8(this.CoupledCount)
for (const mapping of this.ChannelMapping!) {
Expand Down
189 changes: 189 additions & 0 deletions lib/moq-publisher/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// src/components/publisher-moq.ts

import STYLE_SHEET from "./publisher-moq.css"
import { PublisherApi, PublisherOptions } from "../publish"

export class PublisherMoq extends HTMLElement {
private shadow: ShadowRoot
private cameraSelect!: HTMLSelectElement
private microphoneSelect!: HTMLSelectElement
private previewVideo!: HTMLVideoElement
private connectButton!: HTMLButtonElement
private playbackUrlTextarea!: HTMLTextAreaElement
private mediaStream: MediaStream | null = null

private publisher?: PublisherApi
private isPublishing = false
private namespace = ""

constructor() {
super()
this.shadow = this.attachShadow({ mode: "open" })

// CSS
const style = document.createElement("style")
style.textContent = STYLE_SHEET
this.shadow.appendChild(style)

const container = document.createElement("div")
container.classList.add("publisher-container")

this.cameraSelect = document.createElement("select")
this.microphoneSelect = document.createElement("select")
this.previewVideo = document.createElement("video")
this.connectButton = document.createElement("button")
this.playbackUrlTextarea = document.createElement("textarea")

this.previewVideo.autoplay = true
this.previewVideo.playsInline = true
this.previewVideo.muted = true
this.connectButton.textContent = "Connect"

this.playbackUrlTextarea.readOnly = true
this.playbackUrlTextarea.rows = 3
this.playbackUrlTextarea.style.display = "none"
this.playbackUrlTextarea.style.width = "100%"
this.playbackUrlTextarea.style.marginTop = "1rem"

container.append(
this.cameraSelect,
this.microphoneSelect,
this.previewVideo,
this.connectButton,
this.playbackUrlTextarea,
)
this.shadow.appendChild(container)

// Bindings
this.handleDeviceChange = this.handleDeviceChange.bind(this)
this.handleClick = this.handleClick.bind(this)

// Listeners
navigator.mediaDevices.addEventListener("devicechange", this.handleDeviceChange)

Check failure on line 62 in lib/moq-publisher/index.ts

View workflow job for this annotation

GitHub Actions / check

Promise returned in function argument where a void return was expected
this.cameraSelect.addEventListener("change", () => this.startPreview())
this.microphoneSelect.addEventListener("change", () => this.startPreview())
this.connectButton.addEventListener("click", this.handleClick)
}

connectedCallback() {
this.populateDeviceLists()
}

disconnectedCallback() {
navigator.mediaDevices.removeEventListener("devicechange", this.handleDeviceChange)
}

private async handleDeviceChange() {
await this.populateDeviceLists()
}

private async populateDeviceLists() {
const devices = await navigator.mediaDevices.enumerateDevices()
const vids = devices.filter((d) => d.kind === "videoinput")
const mics = devices.filter((d) => d.kind === "audioinput")

this.cameraSelect.innerHTML = ""
this.microphoneSelect.innerHTML = ""

vids.forEach((d) => {
const o = document.createElement("option")
o.value = d.deviceId
o.textContent = d.label || `Camera ${this.cameraSelect.length + 1}`
this.cameraSelect.append(o)
})
mics.forEach((d) => {
const o = document.createElement("option")
o.value = d.deviceId
o.textContent = d.label || `Mic ${this.microphoneSelect.length + 1}`
this.microphoneSelect.append(o)
})

await this.startPreview()
}

private async startPreview() {
const vidId = this.cameraSelect.value
const micId = this.microphoneSelect.value
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((t) => t.stop())
}
this.mediaStream = await navigator.mediaDevices.getUserMedia({
video: vidId ? { deviceId: { exact: vidId } } : true,
audio: micId ? { deviceId: { exact: micId } } : true,
})

this.previewVideo.srcObject = this.mediaStream
}

private async handleClick() {
if (!this.isPublishing) {
if (!this.mediaStream) {
console.warn("No media stream available")
return
}

this.namespace = this.getAttribute("namespace") ?? crypto.randomUUID()

const audioTrack = this.mediaStream.getAudioTracks()[0]
const settings = audioTrack.getSettings()

const sampleRate = settings.sampleRate ?? (await new AudioContext()).sampleRate
const numberOfChannels = settings.channelCount ?? 2

const videoConfig: VideoEncoderConfig = {
codec: "avc1.42E01E",
width: this.previewVideo.videoWidth,
height: this.previewVideo.videoHeight,
bitrate: 1000000,
framerate: 60,
}
const audioConfig: AudioEncoderConfig = { codec: "opus", sampleRate, numberOfChannels, bitrate: 64000 }

const opts: PublisherOptions = {
url: this.getAttribute("src")!,
fingerprintUrl: this.getAttribute("fingerprint") ?? undefined,
namespace: [this.namespace],
media: this.mediaStream,
video: videoConfig,
audio: audioConfig,
}

console.log("Publisher Options", opts)

this.publisher = new PublisherApi(opts)

try {
await this.publisher.publish()
this.isPublishing = true
this.connectButton.textContent = "Stop"
this.cameraSelect.disabled = true
this.microphoneSelect.disabled = true

const playbackBaseUrl = this.getAttribute("playbackbaseurl")
if (playbackBaseUrl) {
this.playbackUrlTextarea.value = `${playbackBaseUrl}${this.namespace}`
} else {
this.playbackUrlTextarea.value = this.namespace
}
this.playbackUrlTextarea.style.display = "block"
} catch (err) {
console.error("Publish failed:", err)
}
} else {
try {
await this.publisher!.stop()
} catch (err) {
console.error("Stop failed:", err)
} finally {
this.isPublishing = false
this.connectButton.textContent = "Connect"
this.cameraSelect.disabled = false
this.microphoneSelect.disabled = false
this.playbackUrlTextarea.style.display = "none"
}
}
}
}

customElements.define("publisher-moq", PublisherMoq)
export default PublisherMoq
Loading
Loading