def __init__(self, cfg: ChannelConfig, corr_cfg: "Config"): self.cfg = cfg # Create a Wave object. wave = Wave( abspath(cfg.wav_path), amplification=coalesce(cfg.amplification, corr_cfg.amplification), ) # Flatten wave stereo for trigger and render. tflat = coalesce(cfg.trigger_stereo, corr_cfg.trigger_stereo) rflat = coalesce(cfg.render_stereo, corr_cfg.render_stereo) self.trigger_wave = wave.with_flatten(tflat, return_channels=False) self.render_wave = wave.with_flatten(rflat, return_channels=True) # `subsampling` increases `stride` and decreases `nsamp`. # `width` increases `stride` without changing `nsamp`. tsub = corr_cfg.trigger_subsampling tw = cfg.trigger_width rsub = corr_cfg.render_subsampling rw = cfg.render_width # nsamp = orig / subsampling # stride = subsampling * width def calculate_nsamp(width_ms, sub): width_s = width_ms / 1000 return round(width_s * wave.smp_s / sub) trigger_samp = calculate_nsamp(corr_cfg.trigger_ms, tsub) self.render_samp = calculate_nsamp(corr_cfg.render_ms, rsub) self.trigger_stride = tsub * tw self.render_stride = rsub * rw # Create a Trigger object. if isinstance(cfg.trigger, ITriggerConfig): tcfg = cfg.trigger elif isinstance( cfg.trigger, (CommentedMap, dict)): # CommentedMap may/not be subclass of dict. tcfg = attr.evolve(corr_cfg.trigger, **cfg.trigger) elif cfg.trigger is None: tcfg = corr_cfg.trigger else: raise CorrError( f"invalid per-channel trigger {cfg.trigger}, type={type(cfg.trigger)}, " f"must be (*)TriggerConfig, dict, or None") self.trigger = tcfg( wave=self.trigger_wave, tsamp=trigger_samp, stride=self.trigger_stride, fps=corr_cfg.fps, )
def test_wave_subsampling_off_by_1(is_odd: bool): """When calling wave.get_around(x, N), ensure that result[N//2] == wave[x].""" wave = Wave(prefix + "s16-impulse1000.wav") for stride in range(1, 5 + 1): N = 500 + is_odd halfN = N // 2 result = wave.get_around(1000, N, stride) assert result[halfN] > 0.5, stride result[halfN] = 0 np.testing.assert_almost_equal(result, 0, decimal=3)
def test_wave_subsampling(): wave = Wave("tests/sine440.wav") # period = 48000 / 440 = 109.(09)* wave.get_around(1000, return_nsamp=501, stride=4) # len([:region_len:subsampling]) == ceil(region_len / subsampling) # If region_len % subsampling != 0, len() != region_len // subsampling. stride = 4 region = 100 # diameter = region * stride for i in [-1000, 50000]: data = wave.get_around(i, region, stride) assert (data == 0).all()
def test_stereo_flatten_modes( flatten: Flatten, return_channels: bool, path: str, nchan: int, peaks: Sequence[float], ): """Ensures all Flatten modes are handled properly for stereo and mono signals.""" # return_channels=False <-> triggering. # flatten=stereo -> rendering. # These conditions do not currently coexist. # if not return_channels and flatten == Flatten.Stereo: # return assert nchan == len(peaks) wave = Wave(path) if flatten not in Flatten.modes: with pytest.raises(CorrError): wave.with_flatten(flatten, return_channels) return else: wave = wave.with_flatten(flatten, return_channels) nsamp = wave.nsamp data = wave[:] # wave.data == 2-D array of shape (nsamp, nchan) if flatten == Flatten.Stereo: assert data.shape == (nsamp, nchan) for chan_data, peak in zip(data.T, peaks): assert_full_scale(chan_data, peak) else: if return_channels: assert data.shape == (nsamp, 1) else: assert data.shape == (nsamp, ) # If DiffAvg and in-phase, L-R=0. if flatten == Flatten.DiffAvg: if len(peaks) >= 2 and peaks[0] == peaks[1]: np.testing.assert_equal(data, 0) else: pass # If SumAvg, check average. else: assert flatten == Flatten.SumAvg assert_full_scale(data, np.mean(peaks))
def test_post_stride(post_trigger): """ Test that stride is respected when post_trigger is disabled, and ignored when post_trigger is enabled. """ cfg = trigger_template(post_trigger=post_trigger) wave = Wave("tests/sine440.wav") iters = 5 x0 = 24000 stride = 4 trigger = cfg(wave, tsamp=100, stride=stride, fps=FPS) cache = PerFrameCache() for i in range(1, iters): offset = trigger.get_trigger(x0, cache) if not cfg.post_trigger: assert (offset - x0) % stride == 0, f"iteration {i}" assert abs(offset - x0) < 10, f"iteration {i}" else: # If assertion fails, remove it. assert (offset - x0) % stride != 0, f"iteration {i}" assert abs(offset - x0) <= 2, f"iteration {i}"
def test_stereo_merge(): """Test indexing Wave by slices *or* ints. Flatten using default SumAvg mode.""" # Contains a full-scale sine wave in left channel, and silence in right. # λ=100, nsamp=2000 wave = Wave(prefix + "stereo-sine-left-2000.wav") period = 100 nsamp = 2000 # [-1, 1) from [-32768..32768) int16_step = (1 - -1) / (2**16) assert int16_step == 2**-15 # Check wave indexing dimensions. assert wave[0].shape == () assert wave[:].shape == (nsamp, ) # Check stereo merging. assert_allclose(wave[0], 0) assert_allclose(wave[period], 0) assert_allclose(wave[period // 4], 0.5, atol=int16_step) def check_bound(obj): amax = np.amax(obj) assert amax.shape == () assert_allclose(amax, 0.5, atol=int16_step) assert_allclose(np.amin(obj), -0.5, atol=int16_step) check_bound(wave[:])
def test_stereo_doesnt_overflow(): """Ensure loud stereo tracks do not overflow.""" wave = Wave("tests/stereo in-phase.wav") samp = 100 stride = 1 data = wave.get_around(wave.nsamp // 2, samp, stride) expect(np.amax(data) > 0.99) expect(np.amin(data) < -0.99) # In the absence of overflow, sine waves have no large jumps. # In the presence of overflow, stereo sum will jump between INT_MAX and INT_MIN. # np.mean and rescaling converts to 0.499... and -0.5, which is nearly 1. expect(np.amax(np.abs(np.diff(data))) < 0.5) assert_expectations()
def test_post_stride(post_trigger): """ Test that stride is respected when post_trigger is disabled, and ignored when post_trigger is enabled. """ cfg = trigger_template(post_trigger=post_trigger, post_radius=10) wave = Wave("tests/sine440.wav") iters = 5 x0 = 24000 stride = 4 def trigger(pos): # We have to generate a new trigger object each time, because # CorrelationTrigger.get_trigger() never goes backwards, which violates the # stride quantization we're testing for in the "if not cfg.post_trigger" branch. trigger = cfg(wave, tsamp=150 // stride, stride=stride, fps=FPS) cache = PerFrameCache() return trigger.get_trigger(pos, cache).result init_offset = trigger(x0) for i in range(1, iters): offset = trigger(x0 + i) if not cfg.post_trigger: assert (offset - init_offset) % stride == i % stride, f"iteration {i}" assert offset == pytest.approx(x0, abs=9), f"iteration {i}" else: assert offset == pytest.approx(init_offset, abs=1), f"iteration {i}" assert offset == pytest.approx(x0, abs=1), f"iteration {i}"
def test_trigger(cfg: CorrelationTriggerConfig): wave = Wave("tests/impulse24000.wav") iters = 5 plot = False x0 = 24000 x = x0 - 500 trigger: CorrelationTrigger = cfg(wave, 4000, stride=1, fps=FPS) if plot: BIG = 0.95 SMALL = 0.05 fig, axes = plt.subplots(iters, gridspec_kw=dict( top=BIG, right=BIG, bottom=SMALL, left=SMALL)) # type: Figure, Axes fig.tight_layout() else: axes = range(iters) for i, ax in enumerate(axes): if i: offset = trigger.get_trigger(x, PerFrameCache()) print(offset) assert offset == x0 if plot: ax.plot(trigger._buffer, label=str(i)) ax.grid() if plot: plt.show()
def test_header_larger_than_filesize(): """According to Zeinok, VortexTracker 2.5 produces slightly corrupted WAV files whose RIFF header metadata indicates a filesize larger than the actual filesize. Most programs read the audio chunk fine. Scipy normally rejects such files, raises ValueError("Unexpected end of file.") My version instead accepts such files (but warns WavFileWarning). """ with pytest.warns(WavFileWarning): wave = Wave("tests/header larger than filesize.wav") assert wave
def test_trigger_stride_edges(cfg: CorrelationTriggerConfig): wave = Wave("tests/sine440.wav") # period = 48000 / 440 = 109.(09)* stride = 4 trigger = cfg(wave, tsamp=100, stride=stride, fps=FPS) # real window_samp = window_samp*stride # period = 109 trigger.get_trigger(0, PerFrameCache()) trigger.get_trigger(-1000, PerFrameCache()) trigger.get_trigger(50000, PerFrameCache())
def test_trigger_out_of_bounds(trigger_cfg): """Ensure out-of-bounds triggering with stride does not crash. (why does stride matter? IDK.)""" wave = Wave("tests/sine440.wav") # period = 48000 / 440 = 109.(09)* stride = 4 trigger = trigger_cfg(wave, tsamp=100, stride=stride, fps=FPS) # real window_samp = window_samp*stride # period = 109 trigger.get_trigger(0, PerFrameCache()) trigger.get_trigger(-1000, PerFrameCache()) trigger.get_trigger(50000, PerFrameCache())
def test_trigger_direction(post_trigger, double_negate): """ Right now, MainTrigger is responsible for negating wave.amplification if edge_direction == -1. And triggers should not actually access edge_direction. """ index = 2400 wave = Wave("tests/step2400.wav") if double_negate: wave.amplification = -1 cfg = trigger_template(post_trigger=post_trigger, edge_direction=-1) else: cfg = trigger_template(post_trigger=post_trigger) trigger = cfg(wave, 100, 1, FPS) cfg.edge_direction = None assert trigger._wave.amplification == 1 cache = PerFrameCache() for dx in [-10, 10, 0]: assert trigger.get_trigger(index + dx, cache) == index
def test_wave(wave_path): with warnings.catch_warnings(record=True) as w: # Cause all warnings to always be triggered. warnings.simplefilter("always") wave = Wave(prefix + wave_path) data = wave[:] # Audacity dithers <=16-bit WAV files upon export, creating a few bits of noise. # As a result, amin(data) <= 0. assert -0.01 < np.amin(data) <= 0 assert 0.99 < np.amax(data) <= 1 # check for FutureWarning (raised when determining wavfile type) warns = [o for o in w if issubclass(o.category, FutureWarning)] assert not [str(w) for w in warns]
def test_mean_subtraction(trigger_cfg, mocker: "pytest_mock.MockFixture"): """ Ensure that trigger subtracts mean properly in all configurations. - Due to a regression, mean was not subtracted when sign_strength = 0. This caused get_period() to malfunction. """ wave = Wave("tests/step2400.wav") get_period = mocker.spy(triggers, "get_period") trigger = trigger_cfg(wave, tsamp=100, stride=1, fps=FPS) cache = PerFrameCache() trigger.get_trigger(2600, cache) # step2400.wav (data, *args), kwargs = get_period.call_args assert isinstance(data, np.ndarray) assert abs(np.mean(data)) < 0.01
def test_trigger(trigger_cfg, is_odd: bool, post_trigger): """Ensures that trigger can locate the first positive sample of a -+ step exactly, without off-by-1 errors. See CorrelationTrigger and Wave.get_around() docstrings. """ wave = Wave("tests/step2400.wav") trigger_cfg = attr.evolve(trigger_cfg, post_trigger=post_trigger) iters = 5 plot = False x0 = 2400 x = x0 - 50 trigger: CorrelationTrigger = trigger_cfg(wave, 400 + int(is_odd), stride=1, fps=FPS) if plot: BIG = 0.95 SMALL = 0.05 fig, axes = plt.subplots(iters, gridspec_kw=dict( top=BIG, right=BIG, bottom=SMALL, left=SMALL)) # type: Figure, Axes fig.tight_layout() else: axes = range(iters) for i, ax in enumerate(axes): if i: offset = trigger.get_trigger(x, PerFrameCache()) assert offset == x0, offset if plot: ax.plot(trigger._buffer, label=str(i)) ax.grid() if plot: plt.show()
def test_post_trigger_stride(post_cfg: CorrelationTriggerConfig): cfg = post_cfg wave = Wave("tests/sine440.wav") iters = 5 x0 = 24000 stride = 4 trigger = cfg(wave, tsamp=100, stride=stride, fps=FPS) cache = PerFrameCache() for i in range(1, iters): offset = trigger.get_trigger(x0, cache) if not cfg.post: assert (offset - x0) % stride == 0, f"iteration {i}" assert abs(offset - x0) < 10, f"iteration {i}" else: # If assertion fails, remove it. assert (offset - x0) % stride != 0, f"iteration {i}" assert abs(offset - x0) <= 2, f"iteration {i}"
def test_when_does_trigger_recalc_window(): cfg = trigger_template(recalc_semitones=1.0) wave = Wave("tests/sine440.wav") trigger: CorrelationTrigger = cfg(wave, tsamp=1000, stride=1, fps=FPS) for x in [0, 1, 1000]: assert trigger._is_window_invalid(x), x trigger._prev_period = 100 for x in [0, 99, 101]: assert not trigger._is_window_invalid(x), x for x in [80, 120]: assert trigger._is_window_invalid(x), x trigger._prev_period = 0 x = 0 assert not trigger._is_window_invalid(x), x for x in [1, 100]: assert trigger._is_window_invalid(x), x
def test_post_trigger_radius(): """ Ensure ZeroCrossingTrigger has no off-by-1 errors when locating edges, and slides at a fixed rate if no edge is found. """ wave = Wave("tests/step2400.wav") center = 2400 radius = 5 cfg = ZeroCrossingTriggerConfig() post = cfg(wave, radius, 1, FPS) cache = PerFrameCache(mean=0) for offset in range(-radius, radius + 1): assert post.get_trigger(center + offset, cache) == center, offset for offset in [radius + 1, radius + 2, 100]: assert post.get_trigger(center - offset, cache) == center - offset + radius assert post.get_trigger(center + offset, cache) == center + offset - radius
def test_trigger_stride(cfg: CorrelationTriggerConfig): wave = Wave("tests/sine440.wav") # period = 48000 / 440 = 109.(09)* iters = 5 x0 = 24000 stride = 4 trigger = cfg(wave, tsamp=100, stride=stride, fps=FPS) # real window_samp = window_samp*stride # period = 109 cache = PerFrameCache() for i in range(1, iters): offset = trigger.get_trigger(x0, cache) # Debugging CorrelationTrigger.get_trigger: # from matplotlib import pyplot as plt # plt.plot(data) # plt.plot(prev_buffer) # plt.plot(corr) # When i=0, the data has 3 peaks, the rightmost taller than the center. The # *tips* of the outer peaks are truncated between `left` and `right`. # After truncation, corr[mid+1] is almost identical to corr[mid], for # reasons I don't understand (mid+1 > mid because dithering?). if not cfg.use_edge_trigger: assert (offset - x0) % stride == 0, f"iteration {i}" assert abs(offset - x0) < 10, f"iteration {i}" # The edge trigger activates at x0+1=24001. Likely related: it triggers # when moving from <=0 to >0. This is a necessary evil, in order to # recognize 0-to-positive edges while testing tests/impulse24000.wav . else: # If assertion fails, remove it. assert (offset - x0) % stride != 0, f"iteration {i}" assert abs(offset - x0) <= 2, f"iteration {i}"
def test_incomplete_wav_chunk(): """Tests that WAV files exported from foobar2000 can be loaded properly, without a `ValueError: Incomplete wav chunk.` exception being raised.""" wave = Wave(prefix + "incomplete-wav-chunk.wav") data = wave[:]
def __init__(self, cfg: ChannelConfig, corr_cfg: "Config", channel_idx: int = 0): """channel_idx counts from 0.""" self.cfg = cfg self.label = cfg.label if not self.label: if corr_cfg.default_label is DefaultLabel.FileName: self.label = Path(cfg.wav_path).stem elif corr_cfg.default_label is DefaultLabel.Number: self.label = str(channel_idx + 1) # Create a Wave object. wave = Wave( abspath(cfg.wav_path), amplification=coalesce(cfg.amplification, corr_cfg.amplification), ) # Flatten wave stereo for trigger and render. tflat = coalesce(cfg.trigger_stereo, corr_cfg.trigger_stereo) rflat = coalesce(cfg.render_stereo, corr_cfg.render_stereo) self.trigger_wave = wave.with_flatten(tflat, return_channels=False) self.render_wave = wave.with_flatten(rflat, return_channels=True) # `subsampling` increases `stride` and decreases `nsamp`. # `width` increases `stride` without changing `nsamp`. tsub = corr_cfg.trigger_subsampling tw = cfg.trigger_width rsub = corr_cfg.render_subsampling rw = cfg.render_width # nsamp = orig / subsampling # stride = subsampling * width def calculate_nsamp(width_ms, sub): width_s = width_ms / 1000 return round(width_s * wave.smp_s / sub) trigger_samp = calculate_nsamp(corr_cfg.trigger_ms, tsub) self._render_samp = calculate_nsamp(corr_cfg.render_ms, rsub) self._trigger_stride = tsub * tw self.render_stride = rsub * rw # Create a Trigger object. if isinstance(cfg.trigger, MainTriggerConfig): tcfg = cfg.trigger elif isinstance( cfg.trigger, (CommentedMap, dict) ): # CommentedMap may/not be subclass of dict. tcfg = evolve_compat(corr_cfg.trigger, **cfg.trigger) elif cfg.trigger is None: tcfg = corr_cfg.trigger else: raise CorrError( f"invalid per-channel trigger {cfg.trigger}, type={type(cfg.trigger)}, " f"must be (*)TriggerConfig, dict, or None" ) self.trigger = tcfg( wave=self.trigger_wave, tsamp=trigger_samp, stride=self._trigger_stride, fps=corr_cfg.fps, wave_idx=channel_idx, )
def test_stereo_mmap(): wave = Wave(prefix + "stereo-sine-left-2000.wav") assert isinstance(wave.data, np.memmap)