Skip to content
Open
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
93 changes: 93 additions & 0 deletions tests/osn-tests/src/test_osn_advanced_recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { OBSHandler } from '../util/obs_handler'
import { deleteConfigFiles, sleep } from '../util/general';
import { EOBSInputTypes, EOBSOutputSignal, EOBSOutputType } from '../util/obs_enums';
import { ERecordingFormat, ERecordingQuality } from '../osn';
import * as inputSettings from '../util/input_settings';
import { getMeanVolumeDb } from '../util/media_probe';
import path = require('path');
const fs = require('fs');

Expand Down Expand Up @@ -464,4 +466,95 @@ describe(testName, () => {
videoEncoder.release();
}
});

// Regression for streamlabs/obs-studio-node#1493: audio worked when a source
// was the output source directly, but went silent once wrapped in a scene.
// Root cause was in libobs scene_audio_render_do, which dropped a scene item
// whose canvas is NULL (the default for scene.add() without setting
// sceneItem.video). We deliberately do NOT set sceneItem.video here, so the
// item canvas stays NULL — exactly the case that regressed — and assert the
// recorded audio is not silent.
it('Scene-wrapped source keeps audio when scene item canvas is unset (regression #1493)', async function () {
if (obs.isDarwin()) {
this.skip();
}

const mediaPath = path.join(path.normalize(__dirname), '..', 'media', 'bigbuckbunny.mp4');
const settings: osn.ISettings = Object.assign({}, inputSettings.ffmpegSource);
settings['local_file'] = mediaPath;
settings['looping'] = true;
const source = osn.InputFactory.create(EOBSInputTypes.FFMPEGSource, 'regression-1493-source', settings);

const scene = osn.SceneFactory.create('regression-1493-scene');
const sceneItem = scene.add(source); // intentionally leave sceneItem.video unset -> item canvas stays NULL
osn.Global.setOutputSource(1, scene);

const recording = osn.AdvancedRecordingFactory.create();
recording.path = path.join(path.normalize(__dirname), '..', 'osnData');
recording.format = ERecordingFormat.MP4;
recording.useStreamEncoders = false;
recording.videoEncoder =
osn.VideoEncoderFactory.create('obs_x264', 'video-encoder-regression-1493');
recording.overwrite = false;
recording.noSpace = false;
recording.video = obs.defaultVideoContext;
const track1 = osn.AudioTrackFactory.create(160, 'track1');
osn.AudioTrackFactory.setAtIndex(track1, 1);
recording.signalHandler = (signal) => {obs.signals.push(signal)};

try {
recording.start();

let signalInfo = await obs.getNextSignalInfo(
EOBSOutputType.Recording, EOBSOutputSignal.Start);

if (signalInfo.signal == EOBSOutputSignal.Stop) {
throw Error(GetErrorMessage(
ETestErrorMsg.RecordOutputDidNotStart, signalInfo.code.toString(), signalInfo.error));
}

expect(signalInfo.signal).to.equal(
EOBSOutputSignal.Start, GetErrorMessage(ETestErrorMsg.RecordingOutput));

// Let the looping media play long enough for a meaningful measurement.
await sleep(3000);

recording.stop();

signalInfo = await obs.getNextSignalInfo(
EOBSOutputType.Recording, EOBSOutputSignal.Stopping);
expect(signalInfo.signal).to.equal(
EOBSOutputSignal.Stopping, GetErrorMessage(ETestErrorMsg.RecordingOutput));

signalInfo = await obs.getNextSignalInfo(
EOBSOutputType.Recording, EOBSOutputSignal.Stop);
if (signalInfo.code != 0) {
throw Error(GetErrorMessage(
ETestErrorMsg.RecordOutputStoppedWithError, signalInfo.code.toString(), signalInfo.error));
}

signalInfo = await obs.getNextSignalInfo(
EOBSOutputType.Recording, EOBSOutputSignal.Wrote);
if (signalInfo.code != 0) {
throw Error(GetErrorMessage(
ETestErrorMsg.RecordOutputStoppedWithError, signalInfo.code.toString(), signalInfo.error));
}

const recordedFile = recording.lastFile();
const meanVolumeDb = getMeanVolumeDb(recordedFile);
logInfo(testName, `Scene-wrapped recording mean volume: ${meanVolumeDb} dB (${recordedFile})`);

// Digital silence is ~ -91 dB / -inf; real audio sits well above -40 dB.
expect(meanVolumeDb).to.be.greaterThan(
-80,
`Scene-wrapped source recorded silent audio (mean ${meanVolumeDb} dB) - ` +
`regression of #1493 (NULL scene-item canvas dropped in scene_audio_render_do)`);
} finally {
const videoEncoder = recording.videoEncoder;
osn.AdvancedRecordingFactory.destroy(recording);
videoEncoder.release();
scene.release();
source.release();
}
});
});
52 changes: 52 additions & 0 deletions tests/osn-tests/util/media_probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { spawnSync } from 'child_process';
import * as fs from 'fs';

// Resolves an ffmpeg executable. Honours FFMPEG_PATH (point it at OBS's bundled
// ffmpeg when ffmpeg is not on PATH), otherwise relies on `ffmpeg` from PATH.
function resolveFfmpeg(): string {
const override = process.env.FFMPEG_PATH;
if (override && fs.existsSync(override)) {
return override;
}
return 'ffmpeg';
}

// Returns the mean volume (dBFS) of the first audio stream of `mediaFile`, as
// measured by ffmpeg's volumedetect filter. Digital silence reports about
// -91 dB (or -inf); normal program audio sits well above -40 dB, so a threshold
// such as `> -80` cleanly separates "has audio" from "silent".
//
// Throws if ffmpeg cannot be run or the file has no decodable audio stream.
export function getMeanVolumeDb(mediaFile: string): number {
if (!fs.existsSync(mediaFile)) {
throw new Error(`getMeanVolumeDb: file does not exist: ${mediaFile}`);
}

const ffmpeg = resolveFfmpeg();
const args = [
'-hide_banner', '-nostats',
'-i', mediaFile,
'-map', '0:a:0',
'-af', 'volumedetect',
'-f', 'null', '-',
];

const result = spawnSync(ffmpeg, args, { encoding: 'utf8' });

if (result.error) {
throw new Error(
`getMeanVolumeDb: failed to run ffmpeg ('${ffmpeg}'). ` +
`Install ffmpeg or set FFMPEG_PATH. Cause: ${result.error.message}`);
}

const log = `${result.stderr || ''}${result.stdout || ''}`;
const match = /mean_volume:\s*(-inf|-?\d+(?:\.\d+)?) dB/.exec(log);

if (!match) {
throw new Error(
`getMeanVolumeDb: could not parse mean_volume for ${mediaFile} ` +
`(no audio stream, or ffmpeg failed). ffmpeg output:\n${log}`);
}

return match[1] === '-inf' ? -Infinity : parseFloat(match[1]);
}
Loading