Add custom stereo downmixing and GUI

pull/357/head
nyanpasu64 2019-03-22 12:48:05 -07:00
rodzic 267d1387be
commit 0ed61ff7a4
5 zmienionych plików z 154 dodań i 47 usunięć

Wyświetl plik

@ -832,6 +832,7 @@ class ChannelModel(qc.QAbstractTableModel):
Column("wav_path", path_strip_quotes, "", "WAV Path"),
Column("amplification", float, None, "Amplification\n(override)"),
Column("line_color", str, None, "Line Color"),
Column("render_stereo", str, None, "Render Stereo\nDownmix"),
Column("trigger_width", int, None, "Trigger Width ×"),
Column("render_width", int, None, "Render Width ×"),
Column("trigger__buffer_strength", float, None),
@ -871,7 +872,7 @@ class ChannelModel(qc.QAbstractTableModel):
# data
TRIGGER = "trigger__"
def data(self, index: QModelIndex, role=Qt.DisplayRole) -> qc.QVariant:
def data(self, index: QModelIndex, role=Qt.DisplayRole) -> Any:
col = index.column()
row = index.row()

Wyświetl plik

@ -174,9 +174,15 @@ class MainWindow(QWidget):
with add_row(s, "", BoundComboBox) as self.trigger_stereo:
pass
with add_row(s, tr("Downmix"), BoundLineEdit, name="trigger_stereo"):
pass
with add_row(s, "", BoundComboBox) as self.render_stereo:
pass
with add_row(s, tr("Downmix"), BoundLineEdit, name="render_stereo"):
pass
with append_widget(s, QGroupBox) as self.dockStereo_2:
set_layout(s, QFormLayout)

Wyświetl plik

@ -1,55 +1,114 @@
import copy
import enum
import warnings
from enum import auto
from typing import Union, List
import numpy as np
import corrscope.utils.scipy.wavfile as wavfile
from corrscope.config import CorrError, CorrWarning, TypedEnumDump
from corrscope.config import CorrError, TypedEnumDump
FLOAT = np.single
# Depends on FLOAT
from corrscope.utils.windows import rightpad
@enum.unique
class Flatten(TypedEnumDump):
class Flatten(str, TypedEnumDump):
""" How to flatten a stereo signal. (Channels beyond first 2 are ignored.)
Flatten(0) == Flatten.Stereo == Flatten['Stereo']
"""
# Keep both channels.
Stereo = 0
Stereo = "stereo"
# Mono
Mono = auto() # NOT publicly exposed
Mono = "1" # NOT publicly exposed
# Take sum or difference.
SumAvg = auto()
DiffAvg = auto()
SumAvg = "1 1"
DiffAvg = "1, -1"
def __str__(self):
return self.value
# Both our app and GUI treat:
# - Flatten.SumAvg -> "sum of all channels"
# - "1 1" -> "assert nchan == 2, left + right".
# - "1 0" -> "assert nchan == 2, left".
def __eq__(self, other):
return self is other
def __hash__(self):
return hash(self.value)
modes: List["Flatten"]
assert "1" == str(Flatten.Mono)
assert not "1" == Flatten.Mono
assert not Flatten.Mono == "1"
FlattenOrStr = Union[Flatten, str]
def calc_flatten_matrix(flatten: FlattenOrStr, stereo_nchan: int) -> np.ndarray:
""" Raises CorrError on invalid input.
If flatten is Flatten.Stereo, returns shape=(nchan,nchan) identity matrix.
- (N,nchan) @ (nchan,nchan) = (N,nchan).
Otherwise, returns shape=(nchan) flattening matrix.
- (N,nchan) @ (nchan) = (N)
https://docs.scipy.org/doc/numpy/reference/generated/numpy.matmul.html#numpy.matmul
'''
If the second argument is 1-D,
it is promoted to a matrix by appending a 1 to its dimensions.
After matrix multiplication the appended 1 is removed."
'''
"""
if flatten is Flatten.Stereo:
# 2D identity (results in 2-dim data)
flatten_matrix = np.eye(stereo_nchan, dtype=FLOAT)
# 1D (results in 1-dim data)
elif flatten is Flatten.SumAvg:
flatten_matrix = np.ones(stereo_nchan, dtype=FLOAT) / stereo_nchan
elif flatten is Flatten.DiffAvg:
flatten_matrix = calc_flatten_matrix(str(flatten), stereo_nchan)
flatten_matrix = rightpad(flatten_matrix, stereo_nchan, 0)
else:
words = flatten.replace(",", " ").split()
try:
flatten_matrix = np.array([FLOAT(word) for word in words])
except ValueError as e:
raise CorrError("Invalid stereo flattening matrix") from e
flatten_abs_sum = np.sum(np.abs(flatten_matrix))
if flatten_abs_sum == 0:
raise CorrError("Stereo flattening matrix must have nonzero elements")
flatten_matrix /= flatten_abs_sum
assert flatten_matrix.dtype == FLOAT, flatten_matrix.dtype
return flatten_matrix
_rejected_modes = {Flatten.Mono}
Flatten.modes = [f for f in Flatten.__members__.values() if f not in _rejected_modes]
class Wave:
__slots__ = """
wave_path
amplification offset
smp_s data return_channels _flatten is_mono
nsamp dtype
center max_val
""".split()
smp_s: int
data: "np.ndarray"
"""2-D array of shape (nsamp, nchan)"""
data: np.ndarray
_flatten: Flatten
_flatten: FlattenOrStr
flatten_matrix: np.ndarray
@property
def flatten(self) -> Flatten:
@ -64,13 +123,13 @@ class Wave:
return self._flatten
@flatten.setter
def flatten(self, flatten: Flatten) -> None:
def flatten(self, flatten: FlattenOrStr) -> None:
# Reject invalid modes (including Mono).
if flatten not in Flatten.modes: # type: ignore
if flatten in _rejected_modes:
# Flatten.Mono not in Flatten.modes.
raise CorrError(
f"Wave {self.wave_path} has invalid flatten mode {flatten} "
f"not in {Flatten.modes}"
f"not a numeric string, nor in {Flatten.modes}"
)
# If self.is_mono, converts all non-Stereo modes to Mono.
@ -78,6 +137,8 @@ class Wave:
if self.is_mono and flatten != Flatten.Stereo:
self._flatten = Flatten.Mono
self.flatten_matrix = calc_flatten_matrix(self._flatten, self.stereo_nchan)
def __init__(
self,
wave_path: str,
@ -87,28 +148,26 @@ class Wave:
self.wave_path = wave_path
self.amplification = amplification
self.offset = 0
# self.data: 2-D array of shape (nsamp, nchan)
self.smp_s, self.data = wavfile.read(wave_path, mmap=True)
assert self.data.ndim in [1, 2]
self.is_mono = self.data.ndim == 1
self.flatten = flatten
self.return_channels = False
# Cast self.data to stereo (nsamp, nchan)
if self.is_mono:
self.data.shape = (-1, 1)
self.nsamp, stereo_nchan = self.data.shape
if stereo_nchan > 2:
warnings.warn(
f"File {wave_path} has {stereo_nchan} channels, "
f"only first 2 will be used",
CorrWarning,
)
self.nsamp, self.stereo_nchan = self.data.shape
dtype = self.data.dtype
# Depends on self.stereo_nchan
self.flatten = flatten
# Calculate scaling factor.
dtype = self.data.dtype
def is_type(parent: type) -> bool:
return np.issubdtype(dtype, parent)
@ -152,16 +211,7 @@ class Wave:
data: np.ndarray = self.data[index].astype(FLOAT, subok=False, copy=True)
# Flatten stereo to mono.
flatten = self._flatten # Potentially faster than property getter.
if flatten == Flatten.Mono:
data = data.reshape(-1) # ndarray.flatten() creates copy, is slow.
elif flatten != Flatten.Stereo:
# data.strides = (4,), so data == contiguous float32
if flatten == Flatten.SumAvg:
data = data[..., 0] + data[..., 1]
else:
data = data[..., 0] - data[..., 1]
data /= 2
data = data @ self.flatten_matrix
data -= self.center
data *= self.amplification / self.max_val

Wyświetl plik

@ -145,6 +145,7 @@ def test_config_channel_width_stride(
[Flatten.Stereo, None],
[Flatten.SumAvg, Flatten.Stereo],
[Flatten.Stereo, Flatten.SumAvg],
[Flatten.Stereo, "1 0"],
],
)
def test_per_channel_stereo(

Wyświetl plik

@ -8,7 +8,7 @@ from delayed_assert import expect, assert_expectations
from corrscope.config import CorrError
from corrscope.utils.scipy.wavfile import WavFileWarning
from corrscope.wave import Wave, Flatten
from corrscope.wave import Wave, Flatten, calc_flatten_matrix
prefix = "tests/wav-formats/"
wave_paths = [
@ -43,6 +43,55 @@ def test_wave(wave_path):
# Stereo tests
def arr(*args):
return np.array(args)
def test_calc_flatten_matrix():
nchan = 3
# Test Stereo
np.testing.assert_equal(calc_flatten_matrix(Flatten.Stereo, nchan), np.eye(nchan))
# Test SumAvg on various channel counts
np.testing.assert_equal(calc_flatten_matrix(Flatten.SumAvg, 1), [1])
np.testing.assert_equal(calc_flatten_matrix(Flatten.SumAvg, 2), [0.5, 0.5])
np.testing.assert_equal(calc_flatten_matrix(Flatten.SumAvg, 4), [0.25] * 4)
# Test DiffAvg on various channel counts
# (Wave will use Mono instead of DiffAvg, on mono audio signals.
# But ensure it doesn't crash anyway.)
np.testing.assert_equal(calc_flatten_matrix(Flatten.DiffAvg, 1), [0.5])
np.testing.assert_equal(calc_flatten_matrix(Flatten.DiffAvg, 2), [0.5, -0.5])
np.testing.assert_equal(calc_flatten_matrix(Flatten.DiffAvg, 4), [0.5, -0.5, 0, 0])
# Test Mono
np.testing.assert_equal(calc_flatten_matrix(Flatten.Mono, 1), [1])
# Test custom strings and delimiters
out = arr(1, 2, 1)
nchan = 3
np.testing.assert_equal(calc_flatten_matrix(",1,2,1,", nchan), out / sum(out))
np.testing.assert_equal(calc_flatten_matrix(" 1, 2, 1 ", nchan), out / sum(out))
np.testing.assert_equal(calc_flatten_matrix("1 2 1", nchan), out / sum(out))
# Test negative values
nchan = 2
np.testing.assert_equal(calc_flatten_matrix("1, -1", nchan), arr(1, -1) / 2)
np.testing.assert_equal(calc_flatten_matrix("-1, 1", nchan), arr(-1, 1) / 2)
np.testing.assert_equal(calc_flatten_matrix("-1, -1", nchan), arr(-1, -1) / 2)
# Test invalid inputs
with pytest.raises(CorrError):
calc_flatten_matrix("", 0)
with pytest.raises(CorrError):
calc_flatten_matrix("1 -1 uwu", 3)
with pytest.raises(CorrError):
calc_flatten_matrix("0 0", 2)
def test_stereo_merge():
"""Test indexing Wave by slices *or* ints. Flatten using default SumAvg mode."""
@ -75,7 +124,7 @@ def test_stereo_merge():
check_bound(wave[:])
AllFlattens = Flatten.__members__.values()
AllFlattens = [*Flatten.__members__.values(), "1 1", "1 0", "1 -1"]
@pytest.mark.parametrize("flatten", AllFlattens)
@ -107,7 +156,7 @@ def test_stereo_flatten_modes(
assert nchan == len(peaks)
wave = Wave(path)
if flatten not in Flatten.modes:
if flatten is Flatten.Mono:
with pytest.raises(CorrError):
wave.with_flatten(flatten, return_channels)
return
@ -135,9 +184,9 @@ def test_stereo_flatten_modes(
else:
pass
# If SumAvg, check average.
else:
assert flatten == Flatten.SumAvg
elif flatten == Flatten.SumAvg:
assert_full_scale(data, np.mean(peaks))
# Don't test custom string modes for now.
def assert_full_scale(data, peak):