openvibers/openvibe.media Commits
RTP ingest records an audio-only broadcast (#240)
Changes
@@@ -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 };}nnew file mode 100644@@@ -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); });
@@ -134,23 +134,26 @@ function buildRtpSdp(video, audio, videoPort, videoRtcpPort, audioPort, audioRtc
@@ -134,23 +134,26 @@ function buildRtpSdp(video, audio, videoPort, videoRtcpPort, audioPort, audioRtc
't=0 0',
'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) {
if (audio && audioPort) {
const aPT = audio.payloadType;
const aPT = audio.payloadType;
@@ -405,13 +408,19 @@ class StreamRecorder {
@@ -405,13 +408,19 @@ class StreamRecorder {
async startRtp(vod, video, audio) {
async startRtp(vod, video, audio) {
const guard = this._guardCanRecord(vod);
const guard = this._guardCanRecord(vod);
if (!guard.ok) return guard;
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 audioPort = null;
let audioRtcpPort = null;
let audioRtcpPort = null;
if (audio && audio.payloadType != null) {
if (audio && audio.payloadType != null) {
@@ -458,10 +467,11 @@ class StreamRecorder {
@@ -458,10 +467,11 @@ class StreamRecorder {
// browser support since Opus-in-MP4 is patchy); VP8/VP9 → .webm (video +
// browser support since Opus-in-MP4 is patchy); VP8/VP9 → .webm (video +
// Opus copied, both native to WebM). Anything exotic falls back to the
// Opus copied, both native to WebM). Anything exotic falls back to the
// legacy libvpx re-encode (+ lossless master when H.264/Opus).
// 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 isH264 = vCodec.includes('h264');
const isVpx = vCodec.includes('vp8') || vCodec.includes('vp9');
const isVpx = vCodec.includes('vp8') || vCodec.includes('vp9');
const passthrough = isH264 || isVpx;
const passthrough = isH264 || isVpx;
+ const audioOnly = !video;
const ext = isH264 ? '.mp4' : '.webm';
const ext = isH264 ? '.mp4' : '.webm';
const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}${ext}`;
const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}${ext}`;
@@ -471,7 +481,11 @@ class StreamRecorder {
@@ -471,7 +481,11 @@ class StreamRecorder {
const normalizeVod = process.env.VOD_NO_NORMALIZE !== '1';
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';
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');
ffmpegArgs.push('-map', '0:v:0', '-c:v', 'copy');
if (audioPort) {
if (audioPort) {
ffmpegArgs.push('-map', '0:a?');
ffmpegArgs.push('-map', '0:a?');
@@ -541,8 +555,8 @@ class StreamRecorder {
@@ -541,8 +555,8 @@ class StreamRecorder {
this._wireProcess(this.activeRecordings.get(vod.id), 'rtp'); // before the row write (see startRtmp)
this._wireProcess(this.activeRecordings.get(vod.id), 'rtp'); // before the row write (see startRtmp)
await persisted;
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 };
return { ok: true, videoPort, audioPort, filePath };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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); });