def test_preview_performance(Popen, mocker: "pytest_mock.MockFixture", outputs): """ Ensure performance optimizations enabled if all outputs are FFplay or others. """ get_frame = mocker.spy(MatplotlibRenderer, "get_frame") previews, records = previews_records(mocker) cfg = cfg_192x108() corr = CorrScope(cfg, Arguments(".", outputs)) corr.play() # Check that only before_preview() called. for p in previews: p.assert_called() for r in records: r.assert_not_called() # Check renderer is 128x72 assert corr.renderer.cfg.width == 128 assert corr.renderer.cfg.height == 72 # Ensure subfps is enabled (only odd frames are rendered, 1..29). # See CorrScope `should_render` variable. assert (get_frame.call_count == round( cfg.end_time * cfg.fps / cfg.render_subfps) == 15)
def test_render_subfps_one(): """ Ensure video gets rendered when render_subfps=1. This test fails if ceildiv is used to calculate `ahead`. """ from corrscope.outputs import IOutputConfig, Output, register_output # region DummyOutput class DummyOutputConfig(IOutputConfig): pass @register_output(DummyOutputConfig) class DummyOutput(Output): frames_written = 0 @classmethod def write_frame(cls, frame: bytes) -> None: cls.frames_written += 1 assert DummyOutput # endregion # Create CorrScope with render_subfps=1. Ensure multiple frames are outputted. cfg = sine440_config() cfg.render_subfps = 1 corr = CorrScope(cfg, Arguments(".", [DummyOutputConfig()])) corr.play() assert DummyOutput.frames_written >= 2
def test_renderer_knows_stride(mocker: "pytest_mock.MockFixture", integration: bool): """ If Renderer draws both "main line" and "custom mono lines" at once, each line must have its x-coordinates multiplied by the stride. Renderer uses "main line stride = 1" by default, but this results in the main line appearing too narrow compared to debug lines. Make sure CorrScope.play() gives Renderer the correct values. """ # Stub out FFplay output. mocker.patch.object(FFplayOutputConfig, "cls") subsampling = 2 width_mul = 3 chan_cfg = ChannelConfig("tests/sine440.wav", render_width=width_mul) corr_cfg = template_config( render_subsampling=subsampling, channels=[chan_cfg], end_time=0 ) if integration: corr = CorrScope(corr_cfg, Arguments(".", [FFplayOutputConfig()])) corr.play() assert corr.renderer.render_strides == [subsampling * width_mul] else: channel = Channel(chan_cfg, corr_cfg, channel_idx=0) data = channel.get_render_around(0) renderer = Renderer( corr_cfg.render, corr_cfg.layout, [data], [chan_cfg], [channel] ) assert renderer.render_strides == [subsampling * width_mul]
def test_corr_output_without_audio(): """Ensure running CorrScope with FFmpeg output, with master audio disabled, does not crash. """ cfg = sine440_config() cfg.master_audio = None corr = CorrScope(cfg, Arguments(".", [NULL_FFMPEG_OUTPUT])) # Should not raise exception. corr.play()
def test_corr_terminate_works(): """ Ensure that ffmpeg/ffplay terminate quickly after Python exceptions, when `popen.terminate()` is called. """ cfg = sine440_config() corr = CorrScope(cfg, Arguments(".", [FFplayOutputConfig()])) corr.raise_on_teardown = DummyException with pytest.raises(DummyException): # Raises `subprocess.TimeoutExpired` if popen.terminate() doesn't work. corr.play()
def test_corrscope_main_uses_contextmanager(mocker: "pytest_mock.MockFixture"): """ Ensure CorrScope() main wraps output in context manager. """ FFmpegOutput = mocker.patch.object(FFmpegOutputConfig, "cls") output = FFmpegOutput.return_value cfg = sine440_config() cfg.master_audio = None corr = CorrScope(cfg, Arguments(".", [NULL_FFMPEG_OUTPUT])) corr.play() FFmpegOutput.assert_called() output.__enter__.assert_called() output.__exit__.assert_called()
def test_corr_terminate_works(test): """ Ensure that output exits quickly after output.terminate() is called. What calls output.terminate() -> popen.terminate()? - Cancelling a GUI render sets is_aborted()=True. - corrscope may throw an exception. Either way, ffmpeg should be terminated so it stops writing audio. """ import sys import subprocess from corrscope.outputs import IOutputConfig, register_output, PipeOutput class StayOpenOutputConfig(IOutputConfig): pass @register_output(StayOpenOutputConfig) class StayOpenOutput(PipeOutput): def __init__(self, corr_cfg: "Config", cfg: StayOpenOutputConfig): super().__init__(corr_cfg, cfg) sleep_process = subprocess.Popen( [sys.executable, "-c", "import time; time.sleep(10)"], stdin=subprocess.PIPE, ) self.open(sleep_process) def is_aborted() -> bool: if test.should_raise: raise DummyException return test.should_abort cfg = sine440_config() arg = Arguments(".", [StayOpenOutputConfig()], is_aborted=is_aborted) corr = CorrScope(cfg, arg) if test.should_raise: with pytest.raises(DummyException): # Raises `subprocess.TimeoutExpired` if popen.terminate() doesn't work. corr.play() else: # Raises `subprocess.TimeoutExpired` if popen.terminate() doesn't work. corr.play()
class CorrJob(qc.QObject): is_aborted: Locked[bool] @qc.Slot() def abort(self): self.is_aborted.set(True) def abort_terminate(self): """Sends abort signal to main loop, and terminates all outputs.""" self.abort() if self.corr is not None: for output in self.corr.outputs: output.terminate(from_same_thread=False) finished = qc.Signal() error = qc.Signal(str) ffmpeg_missing = qc.Signal() def __init__(self, cfg: Config, arg: Arguments, mode: PreviewOrRender): qc.QObject.__init__(self) self.is_aborted = Locked(False) self.cfg = cfg self.arg = arg self.arg.is_aborted = self.is_aborted.get self.mode = mode self.corr = None # type: Optional[CorrScope] def run(self) -> None: """Called in separate thread.""" cfg = self.cfg arg = self.arg try: self.corr = CorrScope(cfg, arg) self.corr.play() except paths.MissingFFmpegError: arg.on_end() self.ffmpeg_missing.emit() except Exception as e: arg.on_end() stack_trace = format_stack_trace(e) self.error.emit(stack_trace) else: arg.on_end()
def test_render_subfps_non_integer(mocker: "pytest_mock.MockFixture"): """ Ensure we output non-integer subfps as fractions, and that ffmpeg doesn't crash. TODO does ffmpeg understand decimals?? """ cfg = sine440_config() cfg.fps = 60 cfg.render_subfps = 7 # By default, we output render_fps (ffmpeg -framerate) as a fraction. assert isinstance(cfg.render_fps, Fraction) assert cfg.render_fps != int(cfg.render_fps) assert Fraction(1) == int(1) corr = CorrScope(cfg, Arguments(".", [NULL_FFMPEG_OUTPUT])) corr.play()
def test_corr_terminate_ffplay(Popen, mocker: "pytest_mock.MockFixture"): """ Integration test: Ensure corrscope calls terminate() on ffmpeg and ffplay when Python exceptions occur. """ cfg = sine440_config() corr = CorrScope(cfg, Arguments(".", [FFplayOutputConfig()])) render_frame = mocker.patch.object(MatplotlibRenderer, "render_frame") render_frame.side_effect = DummyException() with pytest.raises(DummyException): corr.play() assert len(corr.outputs) == 1 output: FFplayOutput = corr.outputs[0] for popen in output._pipeline: popen.terminate.assert_called()
def test_stereo_render_integration(mocker: "pytest_mock.MockFixture"): """Ensure corrscope plays/renders in stereo, without crashing.""" # Stub out FFplay output. mocker.patch.object(FFplayOutputConfig, "cls") # Render in stereo. cfg = template_config( channels=[ChannelConfig("tests/stereo in-phase.wav")], render_stereo=Flatten.Stereo, end_time=0.5, # Reduce test duration render=RendererConfig(WIDTH, HEIGHT), ) # Make sure it doesn't crash. corr = CorrScope(cfg, Arguments(".", [FFplayOutputConfig()])) corr.play()
def test_load_yaml_another_dir(mocker, Popen): """YAML file located in `another/dir` should resolve `master_audio`, `channels[]. wav_path`, and video `path` from `another/dir`.""" subdir = "tests" wav = "sine440.wav" mp4 = "sine440.mp4" with pushd(subdir): arg_str = f"{wav} -a {wav}" cfg, outpath = yaml_sink(mocker, arg_str) # type: Config, Path cfg.begin_time = 100 # To skip all actual rendering # Log execution of CorrScope().play() Wave = mocker.spy(corrscope.channel, "Wave") # Same function as used in cli.py and gui/__init__.py. output = cfg.get_ffmpeg_cfg(mp4) corr = CorrScope(cfg, Arguments(subdir, [output])) corr.play() # The .wav path (specified in Config) should be resolved relative to the config # file. wav_abs = abspath(f"{subdir}/{wav}") # The output video path (specified in CLI --render) should be resolved relative to # the shell's working directory. mp4_abs = abspath(mp4) # Test `wave_path` args, kwargs = Wave.call_args (wave_path, ) = args assert wave_path == wav_abs # Test output `master_audio` and video `path` args, kwargs = Popen.call_args argv = args[0] assert argv[-1] == mp4_abs assert f"-i {wav_abs}" in " ".join(argv)
def test_record_performance(Popen, mocker: "pytest_mock.MockFixture", outputs): """ Ensure performance optimizations disabled if any FFmpegOutputConfig is found. """ get_frame = mocker.spy(MatplotlibRenderer, "get_frame") previews, records = previews_records(mocker) cfg = cfg_192x108() corr = CorrScope(cfg, Arguments(".", outputs)) corr.play() # Check that only before_record() called. for p in previews: p.assert_not_called() for r in records: r.assert_called() # Check renderer is 192x108 assert corr.renderer.cfg.width == 192 assert corr.renderer.cfg.height == 108 # Ensure subfps is disabled. assert get_frame.call_count == round(cfg.end_time * cfg.fps) + 1 == 31
def test_load_yaml_another_dir(mocker, Popen): """ YAML file located in `another/dir` should resolve `master_audio`, `channels[]. wav_path`, and video `path` from `another/dir`. """ subdir = "tests" wav = "sine440.wav" mp4 = "sine440.mp4" with pushd(subdir): arg_str = f"{wav} -a {wav}" cfg, outpath = yaml_sink(mocker, arg_str) # type: Config, Path cfg.begin_time = 100 # To skip all actual rendering # Log execution of CorrScope().play() Wave = mocker.spy(corrscope.channel, "Wave") # Issue: this test does not use cli.main() to compute output path. # Possible solution: Call cli.main() via Click runner. output = FFmpegOutputConfig(cli._get_file_name(None, cfg, cli.VIDEO_NAME)) corr = CorrScope(cfg, Arguments(subdir, [output])) corr.play() # Compute absolute paths wav_abs = abspath(f"{subdir}/{wav}") mp4_abs = abspath(f"{subdir}/{mp4}") # Test `wave_path` args, kwargs = Wave.call_args (wave_path, ) = args assert wave_path == wav_abs # Test output `master_audio` and video `path` args, kwargs = Popen.call_args argv = args[0] assert argv[-1] == mp4_abs assert f"-i {wav_abs}" in " ".join(argv)
def test_config_channel_integration( # Channel c_amplification: Optional[float], c_trigger_width: int, c_render_width: int, # Global amplification: float, trigger_ms: int, render_ms: int, tsub: int, rsub: int, default_label: DefaultLabel, override_label: bool, mocker: MockFixture, ): """ (Tautologically) verify: - channel. r_samp (given cfg) - channel.t/r_stride (given cfg.*_subsampling/*_width) - trigger._tsamp, _stride - renderer's method calls(samp, stride) - rendered label (channel.label, given cfg, corr_cfg.default_label) """ # region setup test variables corrscope.corrscope.PRINT_TIMESTAMP = False # Cleanup Hypothesis testing logs Wave = mocker.patch.object(corrscope.channel, "Wave") wave = Wave.return_value def get_around(sample: int, return_nsamp: int, stride: int): return np.zeros(return_nsamp) wave.get_around.side_effect = get_around wave.with_flatten.return_value = wave wave.nsamp = 10000 wave.smp_s = 48000 ccfg = ChannelConfig( "tests/sine440.wav", trigger_width=c_trigger_width, render_width=c_render_width, amplification=c_amplification, label="label" if override_label else "", ) def get_cfg(): return template_config( trigger_ms=trigger_ms, render_ms=render_ms, trigger_subsampling=tsub, render_subsampling=rsub, amplification=amplification, channels=[ccfg], default_label=default_label, trigger=NullTriggerConfig(), benchmark_mode=BenchmarkMode.OUTPUT, ) # endregion cfg = get_cfg() channel = Channel(ccfg, cfg) # Ensure cfg.width_ms etc. are correct assert cfg.trigger_ms == trigger_ms assert cfg.render_ms == render_ms # Ensure channel.window_samp, trigger_subsampling, render_subsampling are correct. def ideal_samp(width_ms, sub): width_s = width_ms / 1000 return pytest.approx( round(width_s * channel.trigger_wave.smp_s / sub), rel=1e-6 ) ideal_tsamp = ideal_samp(cfg.trigger_ms, tsub) ideal_rsamp = ideal_samp(cfg.render_ms, rsub) assert channel._render_samp == ideal_rsamp assert channel._trigger_stride == tsub * c_trigger_width assert channel.render_stride == rsub * c_render_width # Ensure amplification override works args, kwargs = Wave.call_args assert kwargs["amplification"] == coalesce(c_amplification, amplification) ## Ensure trigger uses channel.window_samp and _trigger_stride. trigger = channel.trigger assert trigger._tsamp == ideal_tsamp assert trigger._stride == channel._trigger_stride ## Ensure corrscope calls render using channel._render_samp and _render_stride. corr = CorrScope(cfg, Arguments(cfg_dir=".", outputs=[])) renderer = mocker.patch.object(CorrScope, "_load_renderer").return_value corr.play() # Only Channel.get_render_around() (not NullTrigger) calls wave.get_around(). (_sample, _return_nsamp, _subsampling), kwargs = wave.get_around.call_args assert _return_nsamp == channel._render_samp assert _subsampling == channel.render_stride # Inspect arguments to renderer.update_main_lines() # datas: List[np.ndarray] (datas,), kwargs = renderer.update_main_lines.call_args render_data = datas[0] assert len(render_data) == channel._render_samp # Inspect arguments to renderer.add_labels(). (labels,), kwargs = renderer.add_labels.call_args label = labels[0] if override_label: assert label == "label" else: if default_label is DefaultLabel.FileName: assert label == "sine440" elif default_label is DefaultLabel.Number: assert label == "1" else: assert label == ""