openvibers/openvibe.media Commits

RTP ingest records an audio-only broadcast (#240)

OP OpenVibers committed 13 days ago · d996001d51 · 2 files, +81 −25

Stream 2827 (moosely, 2026-10-30) sent a microphone and no camera; RTP ingest required video, so the stream had no
VOD at all while the Rundown, which listens to the audio, had it. Without video the SDP has only its audio section
and ffmpeg copies the Opus into WebM; the thumbnail, preview and rendition jobs already answer an audio-only source
with a permanent no_video.

Changes

ddiff --git a/server/vod/recorder.js b/server/vod/recorder.js
iindex a59ed3a..de3fb25 100644
---- a/server/vod/recorder.js
++++ b/server/vod/recorder.js
@@@ -134,23 +134,26 @@ function buildRtpSdp(video, audio, videoPort, videoRtcpPort, audioPort, audioRtc
't=0 0',
];
-- const vPT = video.payloadType;
-- const vCodecName = String(video.codec || 'VP8').split('/').pop();
-- const vFeedback = Array.isArray(video.rtcpFeedback) ? video.rtcpFeedback : [];
-- const videoProtocol = vFeedback.length > 0 ? 'RTP/AVPF' : 'RTP/AVP';
-- lines.push(`m=video ${videoPort} ${videoProtocol} ${vPT}`);
-- lines.push(`a=rtpmap:${vPT} ${vCodecName}/${video.clockRate || 90000}`);
-- if (videoRtcpPort) lines.push(`a=rtcp:${videoRtcpPort} IN IP4 127.0.0.1`);
-- if (video.ssrc) lines.push(`a=ssrc:${video.ssrc} cname:record-video`);
-- if (video.parameters) {
-- const fmtp = _formatFmtpParameters(video.parameters);
-- if (fmtp) lines.push(`a=fmtp:${vPT} ${fmtp}`);
-- }
-- for (const fb of vFeedback) {
-- if (!fb || !fb.type) continue;
-- lines.push(`a=rtcp-fb:${vPT} ${fb.type}${fb.parameter ? ` ${fb.parameter}` : ''}`);
++ // An audio-only broadcast (a browser with no camera) has no video section (2026-10-31).
++ if (video && videoPort) {
++ const vPT = video.payloadType;
++ const vCodecName = String(video.codec || 'VP8').split('/').pop();
++ const vFeedback = Array.isArray(video.rtcpFeedback) ? video.rtcpFeedback : [];
++ const videoProtocol = vFeedback.length > 0 ? 'RTP/AVPF' : 'RTP/AVP';
++ lines.push(`m=video ${videoPort} ${videoProtocol} ${vPT}`);
++ lines.push(`a=rtpmap:${vPT} ${vCodecName}/${video.clockRate || 90000}`);
++ if (videoRtcpPort) lines.push(`a=rtcp:${videoRtcpPort} IN IP4 127.0.0.1`);
++ if (video.ssrc) lines.push(`a=ssrc:${video.ssrc} cname:record-video`);
++ if (video.parameters) {
++ const fmtp = _formatFmtpParameters(video.parameters);
++ if (fmtp) lines.push(`a=fmtp:${vPT} ${fmtp}`);
++ }
++ for (const fb of vFeedback) {
++ if (!fb || !fb.type) continue;
++ lines.push(`a=rtcp-fb:${vPT} ${fb.type}${fb.parameter ? ` ${fb.parameter}` : ''}`);
++ }
++ lines.push('a=recvonly');
}
-- lines.push('a=recvonly');
if (audio && audioPort) {
const aPT = audio.payloadType;
@@@ -405,13 +408,19 @@ class StreamRecorder {
async startRtp(vod, video, audio) {
const guard = this._guardCanRecord(vod);
if (!guard.ok) return guard;
-- if (!video || video.payloadType == null || !video.codec) {
-- return { ok: false, error: 'video { payloadType, codec, clockRate } required' };
++ // A browser broadcast with no camera sends only audio (stream 2827, 2026-10-30: moosely's stream had no VOD at
++ // all because this refused it). Audio alone records to an Opus WebM; the thumbnail, preview and rendition jobs
++ // already answer an audio-only source with a permanent `no_video`.
++ const hasVideo = Boolean(video && video.payloadType != null && video.codec);
++ const hasAudio = Boolean(audio && audio.payloadType != null);
++ if (!hasVideo && !hasAudio) {
++ return { ok: false, error: 'video { payloadType, codec, clockRate } or audio { payloadType, codec, clockRate } required' };
}
++ if (!hasVideo) video = null;
-- const videoPort = allocatePortPair();
-- if (videoPort == null) return { ok: false, error: 'RTP port pool exhausted' };
-- const videoRtcpPort = videoPort + 1;
++ const videoPort = hasVideo ? allocatePortPair() : null;
++ if (hasVideo && videoPort == null) return { ok: false, error: 'RTP port pool exhausted' };
++ const videoRtcpPort = videoPort ? videoPort + 1 : null;
let audioPort = null;
let audioRtcpPort = null;
if (audio && audio.payloadType != null) {
@@@ -458,10 +467,11 @@ class StreamRecorder {
// browser support since Opus-in-MP4 is patchy); VP8/VP9 → .webm (video +
// Opus copied, both native to WebM). Anything exotic falls back to the
// legacy libvpx re-encode (+ lossless master when H.264/Opus).
-- const vCodec = String(video.codec || '').toLowerCase();
++ const vCodec = String((video && video.codec) || '').toLowerCase();
const isH264 = vCodec.includes('h264');
const isVpx = vCodec.includes('vp8') || vCodec.includes('vp9');
const passthrough = isH264 || isVpx;
++ const audioOnly = !video;
const ext = isH264 ? '.mp4' : '.webm';
const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}${ext}`;
@@@ -471,7 +481,11 @@ class StreamRecorder {
const normalizeVod = process.env.VOD_NO_NORMALIZE !== '1';
const VOD_SCALE_VF = 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1';
-- if (passthrough) {
++ if (audioOnly) {
++ // Audio only: Opus copied into WebM as it arrives; another codec is encoded to Opus.
++ const aCodec = String((audio && audio.codec) || '').toLowerCase();
++ ffmpegArgs.push('-map', '0:a:0', ...(aCodec.includes('opus') ? ['-c:a', 'copy'] : ['-c:a', 'libopus', '-b:a', '128k']), '-f', 'webm', filePath);
++ } else if (passthrough) {
ffmpegArgs.push('-map', '0:v:0', '-c:v', 'copy');
if (audioPort) {
ffmpegArgs.push('-map', '0:a?');
@@@ -541,8 +555,8 @@ class StreamRecorder {
this._wireProcess(this.activeRecordings.get(vod.id), 'rtp'); // before the row write (see startRtmp)
await persisted;
-- const recMode = passthrough ? `passthrough ${isH264 ? 'H.264→mp4' : 'VP8/9→webm'} copy` : 'libvpx re-encode';
-- console.log(`[VOD] RTP recording started: vod ${vod.id} → ${filename} [${recMode}] (video:${videoPort}${audioPort ? ` audio:${audioPort}` : ''})`);
++ const recMode = audioOnly ? 'audio only → Opus webm' : passthrough ? `passthrough ${isH264 ? 'H.264→mp4' : 'VP8/9→webm'} copy` : 'libvpx re-encode';
++ console.log(`[VOD] RTP recording started: vod ${vod.id} → ${filename} [${recMode}] (${videoPort ? `video:${videoPort}` : 'no video'}${audioPort ? ` audio:${audioPort}` : ''})`);
return { ok: true, videoPort, audioPort, filePath };
}
ddiff --git a/test/recorder-audio-only.test.js b/test/recorder-audio-only.test.js
nnew file mode 100644
iindex 0000000..1ff01f6
---- /dev/null
++++ b/test/recorder-audio-only.test.js
@@@ -0,0 +1,42 @@
++'use strict';
++// An audio-only browser broadcast records (2026-10-31). Stream 2827 (moosely, 2026-10-30) sent a microphone and no camera,
++// and RTP ingest refused it ("video required"), so the stream had no VOD at all while the Rundown, which listens to the
++// audio, had it. The SDP now leaves out the video section when there is no video, and startRtp takes audio alone.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-rec-audio-'));
++process.env.VOD_STORAGE_PATH = process.env.VOD_STORAGE_PATH || root;
++const recorder = require('../server/vod/recorder');
++
++const audio = { payloadType: 100, codec: 'opus', clockRate: 48000, channels: 2 };
++const video = { payloadType: 101, codec: 'VP8', clockRate: 90000 };
++
++// The SDP: audio only has one m= line, for audio.
++const sdpAudio = recorder.buildRtpSdp(null, audio, null, null, 12002, 12003);
++assert.ok(!/m=video/.test(sdpAudio), 'no video section without video');
++assert.match(sdpAudio, /^m=audio 12002 RTP\/AVP 100$/m, 'the audio section');
++assert.match(sdpAudio, /^a=rtpmap:100 opus\/48000\/2$/m);
++assert.match(sdpAudio, /^a=rtcp:12003 IN IP4 127\.0\.0\.1$/m);
++
++// With video the SDP is as it was.
++const sdpBoth = recorder.buildRtpSdp(video, audio, 12000, 12001, 12002, 12003);
++assert.match(sdpBoth, /^m=video 12000 RTP\/AVP 101$/m);
++assert.match(sdpBoth, /^m=audio 12002 RTP\/AVP 100$/m);
++assert.ok(sdpBoth.indexOf('m=video') < sdpBoth.indexOf('m=audio'), 'video first, as before');
++
++(async () => {
++ // Neither track: refused, and the message names both options.
++ const none = await recorder.startRtp({ id: 999001, app_id: 'live' }, null, null);
++ assert.strictEqual(none.ok, false);
++ assert.match(String(none.error), /video .* or audio/, `the refusal says what is needed: ${none.error}`);
++ // The source carries the audio-only path: one audio map into WebM, Opus copied, no video map.
++ const src = fs.readFileSync(path.join(__dirname, '../server/vod/recorder.js'), 'utf8');
++ assert.match(src, /if \(audioOnly\) \{[\s\S]{0,400}'-map', '0:a:0'[\s\S]{0,200}'-c:a', 'copy'/, 'Opus is copied into WebM');
++ assert.match(src, /const videoPort = hasVideo \? allocatePortPair\(\) : null;/, 'no video port is taken for audio alone');
++ console.log('recorder: an audio-only broadcast records (SDP without video, startRtp takes audio alone) ok');
++ fs.rmSync(root, { recursive: true, force: true });
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });