Skip to content

Commit

Permalink
feat: Add MediaJsonEncoder and an option to export audio to a WS.
Browse files Browse the repository at this point in the history
  • Loading branch information
bgrozev committed Jan 8, 2025
1 parent 96d6959 commit 4523f32
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 1 deletion.
4 changes: 4 additions & 0 deletions jvb/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@
<groupId>${project.groupId}</groupId>
<artifactId>jicoco-jetty</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jicoco-mediajson</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jicoco-metrics</artifactId>
Expand Down
19 changes: 18 additions & 1 deletion jvb/src/main/java/org/jitsi/videobridge/Conference.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.jitsi.utils.logging2.*;
import org.jitsi.utils.queue.*;
import org.jitsi.videobridge.colibri2.*;
import org.jitsi.videobridge.export.*;
import org.jitsi.videobridge.message.*;
import org.jitsi.videobridge.metrics.*;
import org.jitsi.videobridge.relay.*;
Expand All @@ -40,7 +41,6 @@
import org.json.simple.*;
import org.jxmpp.jid.*;

import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
Expand Down Expand Up @@ -180,6 +180,9 @@ public long getLocalVideoSsrc()
@Nullable
private final String meetingId;

@NotNull
private final Exporter exporter = new Exporter();

/**
* A regex pattern to trim UUIDs to just their first 8 hex characters.
*/
Expand Down Expand Up @@ -599,6 +602,7 @@ void expire()
logger.debug(() -> "Expiring endpoints.");
getEndpoints().forEach(AbstractEndpoint::expire);
getRelays().forEach(Relay::expire);
exporter.stop();
speechActivity.expire();

updateStatisticsOnExpire();
Expand Down Expand Up @@ -1118,6 +1122,14 @@ private void sendOut(PacketInfo packetInfo)
prevHandler = relay;
}
}
if (exporter.wants(packetInfo))
{
if (prevHandler != null)
{
prevHandler.send(packetInfo.clone());
}
prevHandler = exporter;
}

if (prevHandler != null)
{
Expand All @@ -1130,6 +1142,11 @@ private void sendOut(PacketInfo packetInfo)
}
}

public void setConnects(List<Connect> exports)
{
exporter.setConnects(exports);
}

public boolean hasRelays()
{
return !relaysById.isEmpty();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class Colibri2ConferenceHandler(
for (e in conferenceModifyIQ.endpoints) {
responseBuilder.addEndpoint(handleColibri2Endpoint(e, ignoreUnknownEndpoints))
}
conferenceModifyIQ.connects?.let { conference.setConnects(it.getConnects()) }
for (r in conferenceModifyIQ.relays) {
if (!RelayConfig.config.enabled) {
throw IqProcessingException(Condition.feature_not_implemented, "Octo is disabled in configuration.")
Expand Down
119 changes: 119 additions & 0 deletions jvb/src/main/kotlin/org/jitsi/videobridge/export/Exporter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright @ 2024 - Present, 8x8 Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.videobridge.export

import org.eclipse.jetty.websocket.api.WebSocketAdapter
import org.eclipse.jetty.websocket.client.ClientUpgradeRequest
import org.eclipse.jetty.websocket.client.WebSocketClient
import org.jitsi.nlj.PacketInfo
import org.jitsi.nlj.format.OpusPayloadType
import org.jitsi.nlj.rtp.AudioRtpPacket
import org.jitsi.nlj.util.PacketInfoQueue
import org.jitsi.utils.logging2.createLogger
import org.jitsi.videobridge.PotentialPacketHandler
import org.jitsi.videobridge.colibri2.FeatureNotImplementedException
import org.jitsi.videobridge.util.ByteBufferPool
import org.jitsi.videobridge.util.TaskPools
import org.jitsi.videobridge.websocket.config.WebsocketServiceConfig
import org.jitsi.xmpp.extensions.colibri2.Connect

class Exporter : PotentialPacketHandler {
val logger = createLogger()
var started = false
val queue = PacketInfoQueue(
"${javaClass.simpleName}-packet-queue",
TaskPools.IO_POOL,
this::doHandlePacket,
1024
)

private var wsNotConnectedErrors = 0
private fun logWsNotConnectedError(): Boolean = (wsNotConnectedErrors++ % 1000) == 0
private val encoder = MediaJsonEncoder {
if (recorderWebSocket.isConnected) {
recorderWebSocket.remote?.sendString(it.toJson())
?: logger.info("Websocket is connected, but remote is null")
} else if (logWsNotConnectedError()) {
logger.info("Can not send packet, websocket is not connected (count=$wsNotConnectedErrors).")
}
}
private var recorderWebSocket = WebSocketAdapter()

fun setConnects(connects: List<Connect>) {
when {
started && connects.isNotEmpty() -> throw FeatureNotImplementedException("Changing connects once enabled.")
connects.isEmpty() -> stop()
connects.size > 1 -> throw FeatureNotImplementedException("Multiple connects")
connects[0].video -> throw FeatureNotImplementedException("Video")
else -> start(connects[0])
}
}

/** Run inside the queue thread, handle a packet. */
private fun doHandlePacket(packet: PacketInfo): Boolean {
if (started) {
encoder.encode(packet.packetAs(), packet.endpointId!!)
}
ByteBufferPool.returnBuffer(packet.packet.buffer)
return true
}

/** Whether we want to accept a packet. */
override fun wants(packet: PacketInfo): Boolean {
if (!started || packet.packet is AudioRtpPacket) return false
if (packet.payloadType !is OpusPayloadType) {
logger.warn("Ignore audio with unsupported payload type: ${packet.payloadType}")
return false
}
return true
}

/** Accept a packet, add it to the queue. */
override fun send(packet: PacketInfo) {
if (started) {
queue.add(packet)
} else {
ByteBufferPool.returnBuffer(packet.packet.buffer)
}
}

fun stop() {
started = false
logger.info("Stopping.")
recorderWebSocket.session?.close(org.eclipse.jetty.websocket.core.CloseStatus.SHUTDOWN, "closing")
}

fun start(connect: Connect) {
if (connect.video) throw FeatureNotImplementedException("Video")
if (connect.protocol != Connect.Protocols.MEDIAJSON) {
throw FeatureNotImplementedException("Protocol ${connect.protocol}")
}
if (connect.type != Connect.Types.RECORDER) {
throw FeatureNotImplementedException("Type ${connect.type}")
}

logger.info("Starting with url=${connect.url}")
webSocketClient.connect(recorderWebSocket, connect.url, ClientUpgradeRequest())
started = true
}

companion object {
val webSocketClient = WebSocketClient().apply {
idleTimeout = WebsocketServiceConfig.config.idleTimeout
start()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright @ 2024 - Present, 8x8 Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jitsi.videobridge.export

import org.jitsi.mediajson.CustomParameters
import org.jitsi.mediajson.Event
import org.jitsi.mediajson.Media
import org.jitsi.mediajson.MediaEvent
import org.jitsi.mediajson.MediaFormat
import org.jitsi.mediajson.Start
import org.jitsi.mediajson.StartEvent
import org.jitsi.nlj.rtp.AudioRtpPacket
import org.jitsi.nlj.util.Rfc3711IndexTracker
import java.time.Clock
import java.time.Duration
import java.time.Instant
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

/**
* Encodes the media in a conference into a mediajson format. Maintains state for each SSRC in order to maintain a
* common space for timestamps.
*
* Note this supports only OPUS and assumes a common clock with a rate of 48000 for all SSRCs (which is required by
* OPUS/RTP).
*/
class MediaJsonEncoder(
/** Encoded mediajson events are sent to this function */
private val handleEvent: (Event) -> Unit
) {
/** Reference time, timestamps are set relative to this instant. **/
private val ref: Instant = Clock.systemUTC().instant()
private val ssrcsStarted = mutableMapOf<Long, SsrcState>()

/** Global sequence number for all events */
var seq = 0

fun encode(p: AudioRtpPacket, epId: String) = synchronized(ssrcsStarted) {
val state = ssrcsStarted.computeIfAbsent(p.ssrc) { ssrc ->
SsrcState(
p.timestamp,
(Duration.between(ref, Clock.systemUTC().instant()).toNanos() * 48.0e-6).toLong()
).also {
handleEvent(createStart(epId, ssrc))
}
}

handleEvent(encodeMedia(p, state, epId))
}

private fun createStart(epId: String, ssrc: Long) = StartEvent(
++seq,
Start(
"$epId-$ssrc",
MediaFormat(
"opus",
48000,
2
),
CustomParameters(endpointId = epId)
)
)

@OptIn(ExperimentalEncodingApi::class)
private fun encodeMedia(p: AudioRtpPacket, state: SsrcState, epId: String): Event {
++seq
val elapsedRtpTime = p.timestamp - state.initialRtpTs
return MediaEvent(
seq,
media = Media(
"$epId-${p.ssrc}",
state.indexTracker.update(p.sequenceNumber),
state.offset + elapsedRtpTime,
Base64.encode(p.buffer, p.payloadOffset, p.payloadOffset + p.payloadLength)
)
)
}

private class SsrcState(
val initialRtpTs: Long,
// Offset of this SSRC since the start time in RTP units
val offset: Long,
val indexTracker: Rfc3711IndexTracker = Rfc3711IndexTracker()
)
}
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@
<artifactId>jicoco-mucclient</artifactId>
<version>${jicoco.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jicoco-mediajson</artifactId>
<version>${jicoco.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jicoco-test-kotlin</artifactId>
Expand Down

0 comments on commit 4523f32

Please sign in to comment.