Пример #1
0
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
Пример #2
0
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)
Пример #3
0
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]
Пример #4
0
    def _get_args(self, outputs: List[IOutputConfig]):
        def raise_exception():
            raise RuntimeError(
                "Arguments.is_aborted should be overwritten by CorrThread")

        arg = Arguments(cfg_dir=self.cfg_dir,
                        outputs=outputs,
                        is_aborted=raise_exception)
        return arg
Пример #5
0
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()
Пример #6
0
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()
Пример #7
0
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()
Пример #8
0
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()
Пример #9
0
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()
Пример #10
0
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()
Пример #11
0
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()
Пример #12
0
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)
Пример #13
0
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
Пример #14
0
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)
Пример #15
0
 def _get_args(self, outputs: List[IOutputConfig]):
     arg = Arguments(cfg_dir=self.cfg_dir, outputs=outputs)
     return arg
Пример #16
0
def main(
    files: Tuple[str],
    # cfg
    audio: Optional[str],
    # gui
    write: bool,
    play: bool,
    render: bool,
    profile: bool,
):
    """Intelligent oscilloscope visualizer for .wav files.

    FILES can be one or more .wav files (or wildcards), one folder, or one
    .yaml config.
    """
    # GUI:
    # corrscope
    # corrscope file.yaml
    # corrscope wildcard/wav/folder ... [--options]
    #
    # CLI:
    # corrscope wildcard/wav/folder ... [--options] --write-cfg file.yaml [--play]
    # corrscope wildcard/wav/folder ... --play
    # corrscope file.yaml --play
    # corrscope file.yaml --write-yaml
    #
    # - You can specify as many wildcards or wav files as you want.
    # - You can only supply one folder, with no files/wildcards.

    show_gui = not any([write, play, render])

    # Gather data for cfg: Config object.
    CfgOrPath = Union[Config, Path]

    cfg_or_path: Union[Config, Path, None] = None
    cfg_dir: Optional[str] = None

    wav_list: List[Path] = []
    for name in files:
        path = Path(name)

        # Windows likes to raise OSError when path contains *
        try:
            is_dir = path.is_dir()
        except OSError:
            is_dir = False
        if is_dir:
            # Add a directory.
            if len(files) > 1:
                # Warning is technically optional, since wav_prefix has been removed.
                raise click.ClickException(
                    f'Cannot supply multiple arguments when providing folder {path}'
                )
            matches = sorted(path.glob('*.wav'))
            wav_list += matches
            break

        elif path.suffix in YAML_EXTS:
            # Load a YAML file to cfg, and skip default_config().
            if len(files) > 1:
                raise click.ClickException(
                    f'Cannot supply multiple arguments when providing config {path}'
                )
            cfg_or_path = path
            cfg_dir = str(path.parent)
            break

        else:
            # Load one or more wav files.
            matches = sorted(Path().glob(name))
            if not matches:
                matches = [path]
                if not path.exists():
                    raise click.ClickException(
                        f'Supplied nonexistent file or wildcard: {path}')
            wav_list += matches

    if not cfg_or_path:
        # cfg and cfg_dir are always initialized together.
        channels = [ChannelConfig(str(wav_path)) for wav_path in wav_list]

        cfg_or_path = default_config(
            master_audio=audio,
            # fps=default,
            channels=channels,
            # width_ms...trigger=default,
            # amplification...render=default,
        )
        cfg_dir = '.'

    assert cfg_or_path is not None
    assert cfg_dir is not None
    if show_gui:

        def command():
            from corrscope import gui
            return gui.gui_main(cast(CfgOrPath, cfg_or_path))

        if profile:
            import cProfile

            # Pycharm can't load CProfile files with dots in the name.
            profile_dump_name = get_profile_dump_name('gui')
            cProfile.runctx('command()', globals(), locals(),
                            profile_dump_name)
        else:
            command()

    else:
        if not files:
            raise click.UsageError('Must specify files or folders to play')

        if isinstance(cfg_or_path, Config):
            cfg = cfg_or_path
            cfg_path = None
        elif isinstance(cfg_or_path, Path):
            cfg = yaml.load(cfg_or_path)
            cfg_path = cfg_or_path
        else:
            assert False, cfg_or_path

        if write:
            write_path = get_path(audio, YAML_NAME)
            yaml.dump(cfg, write_path)

        outputs = []  # type: List[IOutputConfig]

        if play:
            outputs.append(FFplayOutputConfig())

        if render:
            video_path = get_path(cfg_path or audio, VIDEO_NAME)
            outputs.append(FFmpegOutputConfig(video_path))

        if outputs:
            arg = Arguments(cfg_dir=cfg_dir, outputs=outputs)
            command = lambda: CorrScope(cfg, arg).play()
            if profile:
                import cProfile

                # Pycharm can't load CProfile files with dots in the name.
                first_song_name = Path(files[0]).name.split('.')[0]
                profile_dump_name = get_profile_dump_name(first_song_name)
                cProfile.runctx('command()', globals(), locals(),
                                profile_dump_name)
            else:
                try:
                    command()
                except MissingFFmpegError as e:
                    # Tell user how to install ffmpeg (__str__).
                    print(e, file=sys.stderr)
Пример #17
0
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 == ""