diff --git a/ovgenpy/channel.py b/ovgenpy/channel.py index 41bc006..bdb728d 100644 --- a/ovgenpy/channel.py +++ b/ovgenpy/channel.py @@ -56,10 +56,12 @@ class Channel: # nsamp = orig / subsampling # stride = subsampling * width - def calculate_nsamp(sub): - return round(ovgen_cfg.width_s * self.wave.smp_s / sub) - trigger_samp = calculate_nsamp(tsub) - self.render_samp = calculate_nsamp(rsub) + def calculate_nsamp(width_ms, sub): + width_s = width_ms / 1000 + return round(width_s * self.wave.smp_s / sub) + + trigger_samp = calculate_nsamp(ovgen_cfg.trigger_ms, tsub) + self.render_samp = calculate_nsamp(ovgen_cfg.render_ms, rsub) self.trigger_stride = tsub * tw self.render_stride = rsub * rw diff --git a/ovgenpy/cli.py b/ovgenpy/cli.py index 3346793..e5b1257 100644 --- a/ovgenpy/cli.py +++ b/ovgenpy/cli.py @@ -5,7 +5,7 @@ from typing import Optional, List, Tuple, Union import click from ovgenpy.channel import ChannelConfig -from ovgenpy.config import OvgenError, yaml +from ovgenpy.config import yaml from ovgenpy.outputs import IOutputConfig, FFplayOutputConfig, FFmpegOutputConfig from ovgenpy.ovgenpy import default_config, Config, Ovgen @@ -157,10 +157,10 @@ def main( cfg_dir = '.' if show_gui: - raise OvgenError('GUI not implemented') + raise click.UsageError('GUI not implemented') else: if not files: - raise click.ClickException('Must specify files or folders to play') + raise click.UsageError('Must specify files or folders to play') if write: write_path = get_path(audio, YAML_NAME) yaml.dump(cfg, write_path) diff --git a/ovgenpy/config.py b/ovgenpy/config.py index 7d0e59d..0ce811f 100644 --- a/ovgenpy/config.py +++ b/ovgenpy/config.py @@ -9,8 +9,8 @@ if TYPE_CHECKING: __all__ = ['yaml', - 'register_config', 'kw_config', 'Alias', 'Ignored', - 'register_enum', 'OvgenError'] + 'register_config', 'kw_config', 'Alias', 'Ignored', 'register_enum', + 'OvgenError', 'OvgenWarning'] # Setup YAML loading (yaml object). @@ -153,7 +153,13 @@ class _EnumMixin: # Miscellaneous class OvgenError(ValueError): - """ Error caused by invalid end-user input (via CLI or YAML config). """ + """ Error caused by invalid end-user input (via YAML/GUI config). + (Should be) caught by GUI and displayed to user. """ pass +class OvgenWarning(UserWarning): + """ Warning about deprecated end-user config (YAML/GUI). + (Should be) caught by GUI and displayed to user. """ + pass + diff --git a/ovgenpy/ovgenpy.py b/ovgenpy/ovgenpy.py index 3a81c8c..fe4abe4 100644 --- a/ovgenpy/ovgenpy.py +++ b/ovgenpy/ovgenpy.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import time +import warnings from contextlib import ExitStack, contextmanager from enum import unique, IntEnum from fractions import Fraction @@ -10,7 +11,7 @@ import attr from ovgenpy import outputs as outputs_ from ovgenpy.channel import Channel, ChannelConfig -from ovgenpy.config import kw_config, register_enum, Ignored +from ovgenpy.config import kw_config, register_enum, Ignored, OvgenError, OvgenWarning from ovgenpy.renderer import MatplotlibRenderer, RendererConfig from ovgenpy.layout import LayoutConfig from ovgenpy.triggers import ITriggerConfig, CorrelationTriggerConfig, PerFrameCache @@ -41,7 +42,9 @@ class Config: fps: int - width_ms: int + trigger_ms: Optional[int] = None + render_ms: Optional[int] = None + _width_ms: Optional[int] = None # trigger_subsampling and render_subsampling override subsampling. # Always non-None after __attrs_post_init__() @@ -54,6 +57,8 @@ class Config: render_fps = property(lambda self: Fraction(self.fps, self.render_subfps)) + # TODO: Remove cfg._width (breaks compat) + # ISSUE: baking into trigger_ms will stack with channel-specific ms trigger_width: int = 1 render_width: int = 1 @@ -78,17 +83,13 @@ class Config: wav_prefix = Ignored # endregion - @property - def width_s(self) -> float: - return self.width_ms / 1000 - def __attrs_post_init__(self): # Cast benchmark_mode to enum. try: if not isinstance(self.benchmark_mode, BenchmarkMode): self.benchmark_mode = BenchmarkMode[self.benchmark_mode] except KeyError: - raise ValueError( + raise OvgenError( f'invalid benchmark_mode mode {self.benchmark_mode} not in ' f'{[el.name for el in BenchmarkMode]}') @@ -96,6 +97,24 @@ class Config: subsampling = self._subsampling self.trigger_subsampling = coalesce(self.trigger_subsampling, subsampling) self.render_subsampling = coalesce(self.render_subsampling, subsampling) + + # Compute trigger_ms and render_ms. + width_ms = self._width_ms + try: + self.trigger_ms = coalesce(self.trigger_ms, width_ms) + self.render_ms = coalesce(self.render_ms, width_ms) + except TypeError: + raise OvgenError( + 'Must supply either width_ms or both (trigger_ms and render_ms)') + + deprecated = [] + if self.trigger_width != 1: + deprecated.append('trigger_width') + if self.render_width != 1: + deprecated.append('render_width') + if deprecated: + warnings.warn(f"Options {deprecated} are deprecated and will be removed", + OvgenWarning) _FPS = 60 # f_s @@ -108,7 +127,8 @@ def default_config(**kwargs) -> Config: fps=_FPS, amplification=1, - width_ms=40, + trigger_ms=40, + render_ms=40, trigger_subsampling=1, render_subsampling=2, trigger=CorrelationTriggerConfig( diff --git a/ovgenpy/triggers.py b/ovgenpy/triggers.py index a8f540f..4ce86c4 100644 --- a/ovgenpy/triggers.py +++ b/ovgenpy/triggers.py @@ -7,7 +7,7 @@ from scipy import signal from scipy.signal import windows import attr -from ovgenpy.config import kw_config, OvgenError, Alias +from ovgenpy.config import kw_config, OvgenError, Alias, OvgenWarning from ovgenpy.util import find, obj_name from ovgenpy.utils.windows import midpad, leftpad from ovgenpy.wave import FLOAT @@ -140,7 +140,8 @@ class CorrelationTriggerConfig(ITriggerConfig): if self.post: warnings.warn( "Ignoring old `CorrelationTriggerConfig.use_edge_trigger` flag, " - "overriden by newer `post` flag." + "overriden by newer `post` flag.", + OvgenWarning ) else: self.post = ZeroCrossingTriggerConfig() diff --git a/setup.py b/setup.py index 20d118f..183a516 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( author='nyanpasu64', author_email='', description='', - tests_require=['pytest', 'pytest-pycharm', 'hypothesis', 'delayed-assert'], + tests_require=['pytest>=3.2.0', 'pytest-pycharm', 'hypothesis', 'delayed-assert'], install_requires=[ 'numpy', 'scipy', 'click', 'ruamel.yaml', 'matplotlib', diff --git a/tests/test_channel.py b/tests/test_channel.py index 5f2443e..2745eab 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -9,25 +9,48 @@ from pytest_mock import MockFixture import ovgenpy.channel import ovgenpy.ovgenpy from ovgenpy.channel import ChannelConfig, Channel +from ovgenpy.config import OvgenError from ovgenpy.ovgenpy import default_config, Ovgen, BenchmarkMode from ovgenpy.triggers import NullTriggerConfig from ovgenpy.util import coalesce -positive = hs.integers(min_value=1, max_value=100) -maybe = hs.one_of(positive, hs.none()) -@given(subsampling=positive, tsub=maybe, rsub=maybe, - trigger_width=positive, render_width=positive) -def test_channel_subsampling( - subsampling: int, - tsub: Optional[int], - rsub: Optional[int], - trigger_width: int, - render_width: int, - mocker: MockFixture +positive = hs.integers(min_value=1, max_value=100) +Positive = int + +# In order to get good shrinking behaviour, try to put simpler strategies first. +maybe = hs.one_of(hs.none(), positive) +Maybe = Optional[int] + + +@pytest.mark.filterwarnings("ignore::ovgenpy.config.OvgenWarning") +@given( + # Channel + c_trigger_width=maybe, c_render_width=maybe, + + # Global + width_ms=maybe, trigger_ms=maybe, render_ms=maybe, + subsampling=positive, tsub=maybe, rsub=maybe, + g_trigger_width=positive, g_render_width=positive, +) +def test_config_channel_width_stride( + # Channel + c_trigger_width: Maybe, c_render_width: Maybe, + + # Global + width_ms: Maybe, trigger_ms: Maybe, render_ms: Maybe, + subsampling: Positive, tsub: Maybe, rsub: Maybe, + g_trigger_width: Positive, g_render_width: Positive, + + mocker: MockFixture, ): - """ Ensure trigger/render_samp and trigger/render subsampling - are computed correctly. """ + """ (Tautologically) verify: + - cfg.t/r_ms (given width_ms) + - channel. r_samp (given cfg) + - channel.t/r_stride (given cfg.sub/width and cfg.width) + - trigger._tsamp, _stride + - renderer's method calls(samp, stride) + """ # region setup test variables ovgenpy.ovgenpy.PRINT_TIMESTAMP = False # Cleanup Hypothesis testing logs @@ -44,35 +67,56 @@ def test_channel_subsampling( ccfg = ChannelConfig( 'tests/sine440.wav', - trigger_width=trigger_width, - render_width=render_width, + trigger_width=c_trigger_width, + render_width=c_render_width, ) - cfg = default_config( - channels=[ccfg], - subsampling=subsampling, - trigger_subsampling=tsub, - render_subsampling=rsub, - trigger=NullTriggerConfig(), - benchmark_mode=BenchmarkMode.OUTPUT - ) - channel = Channel(ccfg, cfg) + def get_cfg(): + return default_config( + width_ms=width_ms, + trigger_ms=trigger_ms, + render_ms=render_ms, + + subsampling=subsampling, + trigger_subsampling=tsub, + render_subsampling=rsub, + + trigger_width=g_trigger_width, + render_width=g_render_width, + + channels=[ccfg], + trigger=NullTriggerConfig(), + benchmark_mode=BenchmarkMode.OUTPUT + ) # endregion + if not (width_ms or (trigger_ms and render_ms)): + with pytest.raises(OvgenError): + _cfg = get_cfg() + return + + cfg = get_cfg() + channel = Channel(ccfg, cfg) + + # Ensure cfg.width_ms etc. are correct + assert cfg.trigger_ms == coalesce(trigger_ms, width_ms) + assert cfg.render_ms == coalesce(render_ms, width_ms) + # Ensure channel.window_samp, trigger_subsampling, render_subsampling are correct. tsub = coalesce(tsub, subsampling) rsub = coalesce(rsub, subsampling) - def ideal_samp(sub): + def ideal_samp(width_ms, sub): + width_s = width_ms / 1000 return pytest.approx( - round(cfg.width_s * channel.wave.smp_s / sub), abs=1) + round(width_s * channel.wave.smp_s / sub), rel=1e-6) - ideal_tsamp = ideal_samp(tsub) - ideal_rsamp = ideal_samp(rsub) + ideal_tsamp = ideal_samp(cfg.trigger_ms, tsub) + ideal_rsamp = ideal_samp(cfg.render_ms, rsub) assert channel.render_samp == ideal_rsamp del subsampling - assert channel.trigger_stride == tsub * trigger_width - assert channel.render_stride == rsub * render_width + assert channel.trigger_stride == tsub * coalesce(c_trigger_width, g_trigger_width) + assert channel.render_stride == rsub * coalesce(c_render_width, g_render_width) ## Ensure trigger uses channel.window_samp and trigger_stride. trigger = channel.trigger