def test_line_colors(appear: Appearance, data): """Test channel-specific line color overrides""" cfg = get_renderer_config(appear) lcfg = LayoutConfig(orientation=ORIENTATION) datas = [data] * NPLOTS # Move line color (appear.fg.color) from renderer cfg to individual channel. chan = ChannelConfig(wav_path="", line_color=appear.fg.color) channels = [chan] * NPLOTS cfg.init_line_color = "#888888" chan.line_color = appear.fg.color r = Renderer(cfg, lcfg, datas, channels, None) verify(r, appear, datas)
def test_frontend_overrides_backend(mocker: "pytest_mock.MockFixture"): """ class Renderer inherits from (RendererFrontend, backend). RendererFrontend.get_frame() is a wrapper around backend.get_frame() and should override it (RendererFrontend should come first in MRO). Make sure RendererFrontend methods overshadow backend methods. """ # If RendererFrontend.get_frame() override is removed, delete this entire test. frontend_get_frame = mocker.spy(RendererFrontend, "get_frame") backend_get_frame = mocker.spy(AbstractMatplotlibRenderer, "get_frame") corr_cfg = template_config() chan_cfg = ChannelConfig("tests/sine440.wav") 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]) renderer.update_main_lines([data]) renderer.get_frame() assert frontend_get_frame.call_count == 1 assert backend_get_frame.call_count == 1
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 insertRows(self, row: int, count: int, parent=QModelIndex()) -> bool: if not (count >= 1 and 0 <= row <= len(self.channels)): return False self.beginInsertRows(parent, row, row + count - 1) self.channels[row:row] = [ChannelConfig("") for _ in range(count)] self.endInsertRows() return True
def sine440_config(): cfg = default_config( channels=[ChannelConfig("tests/sine440.wav")], master_audio="tests/sine440.wav", end_time=0.5, # Reduce test duration render=render_cfg, ) return cfg
def __init__( self, cfg: RendererConfig, lcfg: "LayoutConfig", dummy_datas: List[np.ndarray], channel_cfgs: Optional[List["ChannelConfig"]], channels: List["Channel"], ): self.cfg = cfg self.lcfg = lcfg self.w = cfg.divided_width self.h = cfg.divided_height # Maps a continuous variable from 0 to 1 (representing one octave) to a color. self.pitch_cmap = gen_circular_cmap(cfg.pitch_colors) self.nplots = len(dummy_datas) if self.nplots > 0: assert len(dummy_datas[0].shape) == 2, dummy_datas[0].shape self.wave_nsamps = [data.shape[0] for data in dummy_datas] self.wave_nchans = [data.shape[1] for data in dummy_datas] if channel_cfgs is None: channel_cfgs = [ChannelConfig("") for _ in range(self.nplots)] if len(channel_cfgs) != self.nplots: raise ValueError( f"cannot assign {len(channel_cfgs)} colors to {self.nplots} plots" ) self._line_params = [ LineParam( color=coalesce(ccfg.line_color, cfg.global_line_color), color_by_pitch=coalesce(ccfg.color_by_pitch, cfg.global_color_by_pitch), ) for ccfg in channel_cfgs ] # Load channel strides. if channels is not None: if len(channels) != self.nplots: raise ValueError( f"cannot assign {len(channels)} channels to {self.nplots} plots" ) self.render_strides = [ channel.render_stride for channel in channels ] else: self.render_strides = [1] * self.nplots
def test_default_colors(appear: Appearance, data): """Test the default background/foreground colors.""" cfg = get_renderer_config(appear) lcfg = LayoutConfig(orientation=ORIENTATION) datas = [data] * NPLOTS r = Renderer(cfg, lcfg, datas, None, None) verify(r, appear, datas) # Ensure default ChannelConfig(line_color=None) does not override line color chan = ChannelConfig(wav_path="") channels = [chan] * NPLOTS r = Renderer(cfg, lcfg, datas, channels, None) verify(r, appear, datas)
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_line_colors(bg_str, fg_str, grid_str, data): """ Test channel-specific line color overrides """ cfg = RendererConfig( WIDTH, HEIGHT, bg_color=bg_str, init_line_color="#888888", grid_color=grid_str, stereo_grid_opacity=OPACITY, line_width=2.0, antialiasing=False, ) lcfg = LayoutConfig() chan = ChannelConfig(wav_path="", line_color=fg_str) channels = [chan] * NPLOTS r = MatplotlibRenderer(cfg, lcfg, NPLOTS, channels) verify(r, bg_str, fg_str, grid_str, data)
def test_per_channel_stereo( filename: str, global_stereo: Flatten, chan_stereo: Optional[Flatten] ): """Ensure you can enable/disable stereo on a per-channel basis.""" stereo = coalesce(chan_stereo, global_stereo) # Test render wave. cfg = template_config(render_stereo=global_stereo) ccfg = ChannelConfig("tests/stereo in-phase.wav", render_stereo=chan_stereo) channel = Channel(ccfg, cfg) # Render wave *must* return stereo. assert channel.render_wave[0:1].ndim == 2 data = channel.render_wave.get_around(0, return_nsamp=4, stride=1) assert data.ndim == 2 if "stereo" in filename: assert channel.render_wave._flatten == stereo assert data.shape[1] == (2 if stereo is Flatten.Stereo else 1)
def test_default_colors(bg_str, fg_str, grid_str, data): """ Test the default background/foreground colors. """ cfg = RendererConfig( WIDTH, HEIGHT, bg_color=bg_str, init_line_color=fg_str, grid_color=grid_str, stereo_grid_opacity=OPACITY, line_width=2.0, antialiasing=False, ) lcfg = LayoutConfig() r = MatplotlibRenderer(cfg, lcfg, NPLOTS, None) verify(r, bg_str, fg_str, grid_str, data) # Ensure default ChannelConfig(line_color=None) does not override line color chan = ChannelConfig(wav_path="") channels = [chan] * NPLOTS r = MatplotlibRenderer(cfg, lcfg, NPLOTS, channels) verify(r, bg_str, fg_str, grid_str, data)
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 == ""
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)