def on_action_render(self): """ Get file name. Then show a progress dialog while rendering to file. """ error_msg = "Cannot render to file, another play/render is active" if self.corr_thread is not None: qw.QMessageBox.critical(self, "Error", error_msg) return video_path = self.file_stem + cli.VIDEO_NAME filters = ["MP4 files (*.mp4)", "All files (*)"] # Points to either `file_dir` or `render_dir`. ref = self.pref.render_dir_ref path = get_save_file_path(ref, self, "Render to Video", video_path, filters, cli.VIDEO_NAME) if path: name = str(path) dlg = CorrProgressDialog(self, "Rendering video") outputs = [FFmpegOutputConfig(name)] self.play_thread(outputs, dlg)
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)
assert popen.stdin != popen.stdout popen.stdin.write.side_effect = exc popen.wait.return_value = 0 return popen Popen = mocker.patch.object(subprocess, "Popen", autospec=True) Popen.side_effect = popen_factory return Popen class DummyException(Exception): pass NULL_FFMPEG_OUTPUT = FFmpegOutputConfig(None, "-f null") render_cfg = RendererConfig(WIDTH, HEIGHT) CFG = default_config(render=render_cfg) 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
class Config( KeywordAttrs, always_dump=""" begin_time end_time render_subfps trigger_subsampling render_subsampling trigger_stereo render_stereo """, ): """Default values indicate optional attributes.""" master_audio: Optional[str] begin_time: float = with_units("s", default=0) end_time: Optional[float] = None fps: int trigger_ms: int = with_units("ms") render_ms: int = with_units("ms") # Performance trigger_subsampling: int = 1 render_subsampling: int = 1 # Performance (skipped when recording to video) render_subfps: int = 2 render_fps = property(lambda self: Fraction(self.fps, self.render_subfps)) # FFmpeg accepts FPS as a fraction. (decimals may work, but are inaccurate.) # Both before_* functions should be idempotent, AKA calling twice does no harm. def before_preview(self) -> None: """Called *once* before preview. Does nothing.""" self.render.before_preview() def before_record(self) -> None: """Called *once* before recording video. Force high-quality rendering.""" self.render_subfps = 1 self.trigger_subsampling = 1 self.render_subsampling = 1 self.render.before_record() # End Performance amplification: float # Stereo config trigger_stereo: FlattenOrStr = Flatten.SumAvg render_stereo: FlattenOrStr = Flatten.SumAvg trigger: CorrelationTriggerConfig # Can be overriden per Wave # Multiplies by trigger_width, render_width. Can override trigger. channels: List[ChannelConfig] default_label: DefaultLabel = DefaultLabel.NoLabel layout: LayoutConfig render: RendererConfig ffmpeg_cli: FFmpegOutputConfig = attr.ib(factory=lambda: FFmpegOutputConfig(None)) def get_ffmpeg_cfg(self, video_path: str) -> FFmpegOutputConfig: return attr.evolve(self.ffmpeg_cli, path=os.path.abspath(video_path)) benchmark_mode: BenchmarkMode = attr.ib( BenchmarkMode.NONE, converter=BenchmarkMode.by_name )
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)