Repository: claritychallenge/clarity (language: python)

Issue:
Unit tests for predictor module
Develop unit tests for the data module (see #80 for overview).

Specific modules within `predictor` are...

- [x] `predictor/torch_msbg.py`
- [x] `predictor/torch_stoi.py`
[pre-commit.ci] pre-commit-autoupdate
<!--pre-commit.ci start-->
updates:
- [github.com/pre-commit/mirrors-mypy: v1.1.1 → v1.2.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.1.1...v1.2.0)
- [github.com/charliermarsh/ruff-pre-commit: v0.0.260 → v0.0.261](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.260...v0.0.261)
<!--pre-commit.ci end-->


Discussion / hints:
torch_msbg contains functions that were already in evaluator/msbg and which appear to have been independently translated from the same MATLAB source. 

- generate_key_percent
- mearure_rms
- makesmearmat3
- audfilt

Some are not being used (replaced with torch implementation), e.g. measure_rms. so can delete. Others, e.. makesmearmat3 need to be deleted and replaced with calls to equivalents in evaluator/msbg -- need to also check that they are equivalent.


Fix patch:
diff --git a/.github/workflows/pypi.yaml b/.github/workflows/pypi.yaml
index e91d4b3f7..4f6a48719 100644
--- a/.github/workflows/pypi.yaml
+++ b/.github/workflows/pypi.yaml
@@ -19,20 +19,22 @@ jobs:
           python-version: 3.9
       - name: Installing the package
         run: |
-          pip3 install versioneer
           pip3 install .
           pip3 install .[pypi]
       - name: Build package
         run: |
+          pip3 install --upgrade setuptools
+          export DEB_PYTHON_INSTALL_LAYOUT=deb_system
           python -m build --no-isolation
       - name: Publish package to PyPI
-        uses: pypa/gh-action-pypi-publish@release/v1
+        uses: pypa/gh-action-pypi-publish@v1.8.3
+        if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
         with:
           user: __token__
           password: ${{ secrets.PYPI_API_TOKEN }}
       - name: Upload release artifacts to Release Notes
         uses: Roang-zero1/github-upload-release-artifacts-action@v2
         with:
-          args "dist/"
+          args: "dist/"
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index f7a061526..ea549f12d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,6 +61,9 @@ venv.bak/
 docs/clarity.*.rst
 docs/recipes.*.rst
 
+# setuptools_scm version
+clarity/_version.py
+
 # Emacs
 *~
 
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 60cd65df4..7128fe320 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,15 +17,16 @@ repos:
       - id: debug-statements
       - id: check-byte-order-marker
       # - id: check-yaml
+      - id: check-toml
 
   - repo: https://github.com/asottile/pyupgrade
     rev: v3.3.1
     hooks:
       - id: pyupgrade
-        args: [--py37-plus]
+        args: [--py38-plus]
 
   - repo: https://github.com/psf/black
-    rev: 23.1.0
+    rev: 23.3.0
     hooks:
       - id: black
         types: [python]
@@ -40,7 +41,7 @@ repos:
     rev: 6.0.0
     hooks:
       - id: flake8
-        additional_dependencies: [flake8-print]
+        additional_dependencies: [flake8-print, Flake8-pyproject]
         types: [python]
 
   - repo: https://github.com/pycqa/isort
@@ -51,7 +52,7 @@ repos:
         args: ["--profile", "black"]
 
   - repo: https://github.com/nbQA-dev/nbQA
-    rev: 1.6.4
+    rev: 1.7.0
     hooks:
       - id: nbqa-black
       - id: nbqa-flake8
@@ -64,19 +65,19 @@ repos:
       - id: nbstripout
 
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.1.1
+    rev: v1.2.0
     hooks:
       - id: mypy
 
   - repo: https://github.com/charliermarsh/ruff-pre-commit
     # Ruff version.
-    rev: "v0.0.257"
+    rev: "v0.0.261"
     hooks:
       - id: ruff
 
   # Serious pylint errors that will be enforced by CI
   - repo: https://github.com/pycqa/pylint
-    rev: v2.17.0
+    rev: v3.0.0a6
     hooks:
       - id: pylint
         args:
@@ -93,6 +94,7 @@ repos:
         language: system
         files: \.py$
         exclude: clarity/_version.py # 3rd party code that doesn't conform
+
 ci:
   autofix_prs: true
   autofix_commit_msg: "[pre-commit.ci] Fixing issues with pre-commit"
diff --git a/.pylintrc b/.pylintrc
index 9a531134d..5e93ce3b6 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -21,10 +21,11 @@ fail-under=10.0
 
 # Files or directories to be skipped. They should be base names, not paths.
 ignore=CVS
+       conf.py
 
 # Add files or directories matching the regex patterns to the ignore-list. The
 # regex matches against paths and can be in Posix or Windows format.
-ignore-paths=
+ignore-paths=clarity/_version.py
 
 # Files or directories matching the regex patterns are skipped. The regex
 # matches against base names, not paths.
@@ -45,7 +46,7 @@ limit-inference-results=100
 
 # List of plugins (as comma separated values of python module names) to load,
 # usually to register additional checkers.
-load-plugins=pylint_pytest
+load-plugins=
 
 # Pickle collected data for later comparisons.
 persistent=yes
diff --git a/README.md b/README.md
index 1b398da4d..f20521297 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@
 [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/claritychallenge/clarity/main.svg)](https://results.pre-commit.ci/latest/github/claritychallenge/clarity/main)
 [![Downloads](https://pepy.tech/badge/pyclarity)](https://pepy.tech/project/pyclarity)
 
+[![PyPI](https://img.shields.io/static/v1?label=CAD1%20and%20CPC2%20Challenges%20-%20pypi&message=v0.3.0&color=orange)](https://pypi.org/project/pyclarity/0.3.0/)
 [![PyPI](https://img.shields.io/static/v1?label=ICASSP%202023%20Challenge%20-%20pypi&message=v0.2.1&color=orange)](https://pypi.org/project/pyclarity/0.2.1/)
 [![PyPI](https://img.shields.io/static/v1?label=CEC2%20Challenge%20-%20pypi&message=v0.1.1&color=orange)](https://pypi.org/project/pyclarity/0.1.1/)
 
@@ -86,7 +87,7 @@ pip install -e git+https://github.com/claritychallenge/clarity.git@main
 Current challenge
 
 - [The 1st Cadenza Challenge (CAD1)](./recipes/cad1)
-- The 1st Clarity Prediction Challenge (CPC2) - Recipes coming soon.
+- [The 2nd Clarity Prediction Challenge (CPC2)](./recipes/cpc2)
 
 Previous challenges
 
diff --git a/clarity/__init__.py b/clarity/__init__.py
index 4620d0c9f..9f9bb6c54 100644
--- a/clarity/__init__.py
+++ b/clarity/__init__.py
@@ -1,4 +1,8 @@
 """pyClarity"""
-from . import _version
+from importlib.metadata import PackageNotFoundError, version
 
-__version__ = _version.get_versions()["version"]
+try:
+    __version__ = version("package-name")
+except PackageNotFoundError:
+    # package is not installed
+    pass
diff --git a/clarity/_version.py b/clarity/_version.py
index 0f8ad9db4..ac74faffb 100644
--- a/clarity/_version.py
+++ b/clarity/_version.py
@@ -1,696 +1,4 @@
-# This file helps to compute a version number in source trees obtained from
-# git-archive tarball (such as those provided by githubs download-from-tag
-# feature). Distribution tarballs (built by setup.py sdist) and build
-# directories (produced by setup.py build) will contain a much shorter file
-# that just contains the computed version number.
-
-# This file is released into the public domain.
-# Generated by versioneer-0.26
-# https://github.com/python-versioneer/python-versioneer
-
-# pylint: disable=W0201  # allow "attribute defined outside __init__"
-
-"""Git implementation of _version.py."""
-
-import errno
-import functools
-import os
-import re
-import subprocess
-import sys
-from typing import Callable, Dict
-
-
-def get_keywords():
-    """Get the keywords needed to look up the version information."""
-    # these strings will be replaced by git during git-archive.
-    # setup.py/versioneer.py will grep for the variable names, so they must
-    # each be defined on a line of their own. _version.py will just call
-    # get_keywords().
-    git_refnames = "$Format:%d$"
-    git_full = "$Format:%H$"
-    git_date = "$Format:%ci$"
-    keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
-    return keywords
-
-
-class VersioneerConfig:
-    """Container for Versioneer configuration parameters."""
-
-
-def get_config():
-    """Create, populate and return the VersioneerConfig() object."""
-    # these strings are filled in when 'setup.py versioneer' creates
-    # _version.py
-    cfg = VersioneerConfig()
-    cfg.VCS = "git"
-    cfg.style = "pep440"
-    cfg.tag_prefix = "v"
-    cfg.parentdir_prefix = ""
-    cfg.versionfile_source = "clarity/_version.py"
-    cfg.verbose = False
-    return cfg
-
-
-class NotThisMethod(Exception):
-    """Exception raised if a method is not valid for the current scenario."""
-
-
-LONG_VERSION_PY: Dict[str, str] = {}
-HANDLERS: Dict[str, Dict[str, Callable]] = {}
-
-
-def register_vcs_handler(vcs, method):  # decorator
-    """Create decorator to mark a method as the handler of a VCS."""
-
-    def decorate(f):
-        """Store f in HANDLERS[vcs][method]."""
-        if vcs not in HANDLERS:
-            HANDLERS[vcs] = {}
-        HANDLERS[vcs][method] = f
-        return f
-
-    return decorate
-
-
-def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None):
-    """Call the given command(s)."""
-    assert isinstance(commands, list)
-    process = None
-
-    popen_kwargs = {}
-    if sys.platform == "win32":
-        # This hides the console window if pythonw.exe is used
-        startupinfo = subprocess.STARTUPINFO()
-        startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
-        popen_kwargs["startupinfo"] = startupinfo
-
-    for command in commands:
-        dispcmd = None
-        try:
-            dispcmd = str([command] + args)
-            # remember shell=False, so use git.cmd on windows, not just git
-            process = subprocess.Popen(  # pylint: disable=consider-using-with
-                [command] + args,
-                cwd=cwd,
-                env=env,
-                stdout=subprocess.PIPE,
-                stderr=(subprocess.PIPE if hide_stderr else None),
-                **popen_kwargs,
-            )
-            break
-        except OSError:
-            e = sys.exc_info()[1]
-            if e.errno == errno.ENOENT:
-                continue
-            if verbose:
-                print("unable to run %s" % dispcmd)
-                print(e)
-            return None, None
-    else:
-        if verbose:
-            print(f"unable to find command, tried {commands}")
-        return None, None
-    stdout = process.communicate()[0].strip().decode()
-    if process.returncode != 0:
-        if verbose:
-            print("unable to run %s (error)" % dispcmd)
-            print("stdout was %s" % stdout)
-        return None, process.returncode
-    return stdout, process.returncode
-
-
-def versions_from_parentdir(parentdir_prefix, root, verbose):
-    """Try to determine the version from the parent directory name.
-
-    Source tarballs conventionally unpack into a directory that includes both
-    the project name and a version string. We will also support searching up
-    two directory levels for an appropriately named parent directory
-    """
-    rootdirs = []
-
-    for _ in range(3):
-        dirname = os.path.basename(root)
-        if dirname.startswith(parentdir_prefix):
-            return {
-                "version": dirname[len(parentdir_prefix) :],
-                "full-revisionid": None,
-                "dirty": False,
-                "error": None,
-                "date": None,
-            }
-        rootdirs.append(root)
-        root = os.path.dirname(root)  # up a level
-
-    if verbose:
-        print(
-            "Tried directories %s but none started with prefix %s"
-            % (str(rootdirs), parentdir_prefix)
-        )
-    raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
-
-
-@register_vcs_handler("git", "get_keywords")
-def git_get_keywords(versionfile_abs):
-    """Extract version information from the given file."""
-    # the code embedded in _version.py can just fetch the value of these
-    # keywords. When used from setup.py, we don't want to import _version.py,
-    # so we do it with a regexp instead. This function is not used from
-    # _version.py.
-    keywords = {}
-    try:
-        with open(versionfile_abs, encoding="utf-8") as fobj:
-            for line in fobj:
-                if line.strip().startswith("git_refnames ="):
-                    mo = re.search(r'=\s*"(.*)"', line)
-                    if mo:
-                        keywords["refnames"] = mo.group(1)
-                if line.strip().startswith("git_full ="):
-                    mo = re.search(r'=\s*"(.*)"', line)
-                    if mo:
-                        keywords["full"] = mo.group(1)
-                if line.strip().startswith("git_date ="):
-                    mo = re.search(r'=\s*"(.*)"', line)
-                    if mo:
-                        keywords["date"] = mo.group(1)
-    except OSError:
-        pass
-    return keywords
-
-
-@register_vcs_handler("git", "keywords")
-def git_versions_from_keywords(keywords, tag_prefix, verbose):
-    """Get version information from git keywords."""
-    if "refnames" not in keywords:
-        raise NotThisMethod("Short version file found")
-    date = keywords.get("date")
-    if date is not None:
-        # Use only the last line.  Previous lines may contain GPG signature
-        # information.
-        date = date.splitlines()[-1]
-
-        # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
-        # datestamp. However we prefer "%ci" (which expands to an "ISO-8601
-        # -like" string, which we must then edit to make compliant), because
-        # it's been around since git-1.5.3, and it's too difficult to
-        # discover which version we're using, or to work around using an
-        # older one.
-        date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
-    refnames = keywords["refnames"].strip()
-    if refnames.startswith("$Format"):
-        if verbose:
-            print("keywords are unexpanded, not using")
-        raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
-    refs = {r.strip() for r in refnames.strip("()").split(",")}
-    # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
-    # just "foo-1.0". If we see a "tag: " prefix, prefer those.
-    TAG = "tag: "
-    tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)}
-    if not tags:
-        # Either we're using git < 1.8.3, or there really are no tags. We use
-        # a heuristic: assume all version tags have a digit. The old git %d
-        # expansion behaves like git log --decorate=short and strips out the
-        # refs/heads/ and refs/tags/ prefixes that would let us distinguish
-        # between branches and tags. By ignoring refnames without digits, we
-        # filter out many common branch names like "release" and
-        # "stabilization", as well as "HEAD" and "master".
-        tags = {r for r in refs if re.search(r"\d", r)}
-        if verbose:
-            print("discarding '%s', no digits" % ",".join(refs - tags))
-    if verbose:
-        print("likely tags: %s" % ",".join(sorted(tags)))
-    for ref in sorted(tags):
-        # sorting will prefer e.g. "2.0" over "2.0rc1"
-        if ref.startswith(tag_prefix):
-            r = ref[len(tag_prefix) :]
-            # Filter out refs that exactly match prefix or that don't start
-            # with a number once the prefix is stripped (mostly a concern
-            # when prefix is '')
-            if not re.match(r"\d", r):
-                continue
-            if verbose:
-                print("picking %s" % r)
-            return {
-                "version": r,
-                "full-revisionid": keywords["full"].strip(),
-                "dirty": False,
-                "error": None,
-                "date": date,
-            }
-    # no suitable tags, so version is "0+unknown", but full hex is still there
-    if verbose:
-        print("no suitable tags, using unknown + full revision id")
-    return {
-        "version": "0+unknown",
-        "full-revisionid": keywords["full"].strip(),
-        "dirty": False,
-        "error": "no suitable tags",
-        "date": None,
-    }
-
-
-@register_vcs_handler("git", "pieces_from_vcs")
-def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command):
-    """Get version from 'git describe' in the root of the source tree.
-
-    This only gets called if the git-archive 'subst' keywords were *not*
-    expanded, and _version.py hasn't already been rewritten with a short
-    version string, meaning we're inside a checked out source tree.
-    """
-    GITS = ["git"]
-    if sys.platform == "win32":
-        GITS = ["git.cmd", "git.exe"]
-
-    # GIT_DIR can interfere with correct operation of Versioneer.
-    # It may be intended to be passed to the Versioneer-versioned project,
-    # but that should not change where we get our version from.
-    env = os.environ.copy()
-    env.pop("GIT_DIR", None)
-    runner = functools.partial(runner, env=env)
-
-    _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=not verbose)
-    if rc != 0:
-        if verbose:
-            print("Directory %s not under git control" % root)
-        raise NotThisMethod("'git rev-parse --git-dir' returned error")
-
-    # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
-    # if there isn't one, this yields HEX[-dirty] (no NUM)
-    describe_out, rc = runner(
-        GITS,
-        [
-            "describe",
-            "--tags",
-            "--dirty",
-            "--always",
-            "--long",
-            "--match",
-            f"{tag_prefix}[[:digit:]]*",
-        ],
-        cwd=root,
-    )
-    # --long was added in git-1.5.5
-    if describe_out is None:
-        raise NotThisMethod("'git describe' failed")
-    describe_out = describe_out.strip()
-    full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root)
-    if full_out is None:
-        raise NotThisMethod("'git rev-parse' failed")
-    full_out = full_out.strip()
-
-    pieces = {}
-    pieces["long"] = full_out
-    pieces["short"] = full_out[:7]  # maybe improved later
-    pieces["error"] = None
-
-    branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
-    # --abbrev-ref was added in git-1.6.3
-    if rc != 0 or branch_name is None:
-        raise NotThisMethod("'git rev-parse --abbrev-ref' returned error")
-    branch_name = branch_name.strip()
-
-    if branch_name == "HEAD":
-        # If we aren't exactly on a branch, pick a branch which represents
-        # the current commit. If all else fails, we are on a branchless
-        # commit.
-        branches, rc = runner(GITS, ["branch", "--contains"], cwd=root)
-        # --contains was added in git-1.5.4
-        if rc != 0 or branches is None:
-            raise NotThisMethod("'git branch --contains' returned error")
-        branches = branches.split("\n")
-
-        # Remove the first line if we're running detached
-        if "(" in branches[0]:
-            branches.pop(0)
-
-        # Strip off the leading "* " from the list of branches.
-        branches = [branch[2:] for branch in branches]
-        if "master" in branches:
-            branch_name = "master"
-        elif not branches:
-            branch_name = None
-        else:
-            # Pick the first branch that is returned. Good or bad.
-            branch_name = branches[0]
-
-    pieces["branch"] = branch_name
-
-    # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
-    # TAG might have hyphens.
-    git_describe = describe_out
-
-    # look for -dirty suffix
-    dirty = git_describe.endswith("-dirty")
-    pieces["dirty"] = dirty
-    if dirty:
-        git_describe = git_describe[: git_describe.rindex("-dirty")]
-
-    # now we have TAG-NUM-gHEX or HEX
-
-    if "-" in git_describe:
-        # TAG-NUM-gHEX
-        mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe)
-        if not mo:
-            # unparsable. Maybe git-describe is misbehaving?
-            pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out
-            return pieces
-
-        # tag
-        full_tag = mo.group(1)
-        if not full_tag.startswith(tag_prefix):
-            if verbose:
-                fmt = "tag '%s' doesn't start with prefix '%s'"
-                print(fmt % (full_tag, tag_prefix))
-            pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format(
-                full_tag,
-                tag_prefix,
-            )
-            return pieces
-        pieces["closest-tag"] = full_tag[len(tag_prefix) :]
-
-        # distance: number of commits since tag
-        pieces["distance"] = int(mo.group(2))
-
-        # commit: short hex revision ID
-        pieces["short"] = mo.group(3)
-
-    else:
-        # HEX: no tags
-        pieces["closest-tag"] = None
-        out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root)
-        pieces["distance"] = len(out.split())  # total number of commits
-
-    # commit date: see ISO-8601 comment in git_versions_from_keywords()
-    date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip()
-    # Use only the last line.  Previous lines may contain GPG signature
-    # information.
-    date = date.splitlines()[-1]
-    pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
-
-    return pieces
-
-
-def plus_or_dot(pieces):
-    """Return a + if we don't already have one, else return a ."""
-    if "+" in pieces.get("closest-tag", ""):
-        return "."
-    return "+"
-
-
-def render_pep440(pieces):
-    """Build up version string, with post-release "local version identifier".
-
-    Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
-    get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
-
-    Exceptions:
-    1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += plus_or_dot(pieces)
-            rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
-            if pieces["dirty"]:
-                rendered += ".dirty"
-    else:
-        # exception #1
-        rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"])
-        if pieces["dirty"]:
-            rendered += ".dirty"
-    return rendered
-
-
-def render_pep440_branch(pieces):
-    """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] .
-
-    The ".dev0" means not master branch. Note that .dev0 sorts backwards
-    (a feature branch will appear "older" than the master branch).
-
-    Exceptions:
-    1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            if pieces["branch"] != "master":
-                rendered += ".dev0"
-            rendered += plus_or_dot(pieces)
-            rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
-            if pieces["dirty"]:
-                rendered += ".dirty"
-    else:
-        # exception #1
-        rendered = "0"
-        if pieces["branch"] != "master":
-            rendered += ".dev0"
-        rendered += "+untagged.%d.g%s" % (pieces["distance"], pieces["short"])
-        if pieces["dirty"]:
-            rendered += ".dirty"
-    return rendered
-
-
-def pep440_split_post(ver):
-    """Split pep440 version string at the post-release segment.
-
-    Returns the release segments before the post-release and the
-    post-release version number (or -1 if no post-release segment is present).
-    """
-    vc = str.split(ver, ".post")
-    return vc[0], int(vc[1] or 0) if len(vc) == 2 else None
-
-
-def render_pep440_pre(pieces):
-    """TAG[.postN.devDISTANCE] -- No -dirty.
-
-    Exceptions:
-    1: no tags. 0.post0.devDISTANCE
-    """
-    if pieces["closest-tag"]:
-        if pieces["distance"]:
-            # update the post release segment
-            tag_version, post_version = pep440_split_post(pieces["closest-tag"])
-            rendered = tag_version
-            if post_version is not None:
-                rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"])
-            else:
-                rendered += ".post0.dev%d" % (pieces["distance"])
-        else:
-            # no commits, use the tag as the version
-            rendered = pieces["closest-tag"]
-    else:
-        # exception #1
-        rendered = "0.post0.dev%d" % pieces["distance"]
-    return rendered
-
-
-def render_pep440_post(pieces):
-    """TAG[.postDISTANCE[.dev0]+gHEX] .
-
-    The ".dev0" means dirty. Note that .dev0 sorts backwards
-    (a dirty tree will appear "older" than the corresponding clean one),
-    but you shouldn't be releasing software with -dirty anyways.
-
-    Exceptions:
-    1: no tags. 0.postDISTANCE[.dev0]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += ".post%d" % pieces["distance"]
-            if pieces["dirty"]:
-                rendered += ".dev0"
-            rendered += plus_or_dot(pieces)
-            rendered += "g%s" % pieces["short"]
-    else:
-        # exception #1
-        rendered = "0.post%d" % pieces["distance"]
-        if pieces["dirty"]:
-            rendered += ".dev0"
-        rendered += "+g%s" % pieces["short"]
-    return rendered
-
-
-def render_pep440_post_branch(pieces):
-    """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] .
-
-    The ".dev0" means not master branch.
-
-    Exceptions:
-    1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += ".post%d" % pieces["distance"]
-            if pieces["branch"] != "master":
-                rendered += ".dev0"
-            rendered += plus_or_dot(pieces)
-            rendered += "g%s" % pieces["short"]
-            if pieces["dirty"]:
-                rendered += ".dirty"
-    else:
-        # exception #1
-        rendered = "0.post%d" % pieces["distance"]
-        if pieces["branch"] != "master":
-            rendered += ".dev0"
-        rendered += "+g%s" % pieces["short"]
-        if pieces["dirty"]:
-            rendered += ".dirty"
-    return rendered
-
-
-def render_pep440_old(pieces):
-    """TAG[.postDISTANCE[.dev0]] .
-
-    The ".dev0" means dirty.
-
-    Exceptions:
-    1: no tags. 0.postDISTANCE[.dev0]
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"] or pieces["dirty"]:
-            rendered += ".post%d" % pieces["distance"]
-            if pieces["dirty"]:
-                rendered += ".dev0"
-    else:
-        # exception #1
-        rendered = "0.post%d" % pieces["distance"]
-        if pieces["dirty"]:
-            rendered += ".dev0"
-    return rendered
-
-
-def render_git_describe(pieces):
-    """TAG[-DISTANCE-gHEX][-dirty].
-
-    Like 'git describe --tags --dirty --always'.
-
-    Exceptions:
-    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        if pieces["distance"]:
-            rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
-    else:
-        # exception #1
-        rendered = pieces["short"]
-    if pieces["dirty"]:
-        rendered += "-dirty"
-    return rendered
-
-
-def render_git_describe_long(pieces):
-    """TAG-DISTANCE-gHEX[-dirty].
-
-    Like 'git describe --tags --dirty --always -long'.
-    The distance/hash is unconditional.
-
-    Exceptions:
-    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
-    """
-    if pieces["closest-tag"]:
-        rendered = pieces["closest-tag"]
-        rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
-    else:
-        # exception #1
-        rendered = pieces["short"]
-    if pieces["dirty"]:
-        rendered += "-dirty"
-    return rendered
-
-
-def render(pieces, style):
-    """Render the given version pieces into the requested style."""
-    if pieces["error"]:
-        return {
-            "version": "unknown",
-            "full-revisionid": pieces.get("long"),
-            "dirty": None,
-            "error": pieces["error"],
-            "date": None,
-        }
-
-    if not style or style == "default":
-        style = "pep440"  # the default
-
-    if style == "pep440":
-        rendered = render_pep440(pieces)
-    elif style == "pep440-branch":
-        rendered = render_pep440_branch(pieces)
-    elif style == "pep440-pre":
-        rendered = render_pep440_pre(pieces)
-    elif style == "pep440-post":
-        rendered = render_pep440_post(pieces)
-    elif style == "pep440-post-branch":
-        rendered = render_pep440_post_branch(pieces)
-    elif style == "pep440-old":
-        rendered = render_pep440_old(pieces)
-    elif style == "git-describe":
-        rendered = render_git_describe(pieces)
-    elif style == "git-describe-long":
-        rendered = render_git_describe_long(pieces)
-    else:
-        raise ValueError("unknown style '%s'" % style)
-
-    return {
-        "version": rendered,
-        "full-revisionid": pieces["long"],
-        "dirty": pieces["dirty"],
-        "error": None,
-        "date": pieces.get("date"),
-    }
-
-
-def get_versions():
-    """Get version information or return default if unable to do so."""
-    # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
-    # __file__, we can work backwards from there to the root. Some
-    # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
-    # case we can only use expanded keywords.
-
-    cfg = get_config()
-    verbose = cfg.verbose
-
-    try:
-        return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose)
-    except NotThisMethod:
-        pass
-
-    try:
-        root = os.path.realpath(__file__)
-        # versionfile_source is the relative path from the top of the source
-        # tree (where the .git directory might live) to this file. Invert
-        # this to find the root from __file__.
-        for _ in cfg.versionfile_source.split("/"):
-            root = os.path.dirname(root)
-    except NameError:
-        return {
-            "version": "0+unknown",
-            "full-revisionid": None,
-            "dirty": None,
-            "error": "unable to find root of source tree",
-            "date": None,
-        }
-
-    try:
-        pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
-        return render(pieces, cfg.style)
-    except NotThisMethod:
-        pass
-
-    try:
-        if cfg.parentdir_prefix:
-            return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
-    except NotThisMethod:
-        pass
-
-    return {
-        "version": "0+unknown",
-        "full-revisionid": None,
-        "dirty": None,
-        "error": "unable to compute version",
-        "date": None,
-    }
+# file generated by setuptools_scm
+# don't change, don't track in version control
+__version__ = version = "0.2.2.dev508+g2611c0d.d20230327"
+__version_tuple__ = version_tuple = (0, 2, 2, "dev508", "g2611c0d.d20230327")
diff --git a/clarity/data/utils.py b/clarity/data/utils.py
index 9f851ebec..a639cfa5c 100644
--- a/clarity/data/utils.py
+++ b/clarity/data/utils.py
@@ -1,14 +1,8 @@
 """Utilities for data generation."""
 from __future__ import annotations
 
-# pylint: disable=wrong-import-position
-import sys
 from pathlib import Path
-
-if sys.version_info >= (3, 8):
-    from typing import Literal
-else:
-    from typing_extensions import Literal
+from typing import Literal
 
 import numpy as np
 import scipy
diff --git a/clarity/enhancer/compressor.py b/clarity/enhancer/compressor.py
index 5ec3f159b..865208dab 100644
--- a/clarity/enhancer/compressor.py
+++ b/clarity/enhancer/compressor.py
@@ -17,7 +17,7 @@ def __init__(
         rms_buffer_size: float = 0.2,
         makeup_gain: int = 1,
         **_kwargs,
-    ):
+    ) -> None:
         """Instantiate the Compressor Class.
 
         Args:
@@ -68,7 +68,7 @@ def set_release(self, t_msec: float) -> None:
         reciprocal_time = 1 / t_sec
         self.release = reciprocal_time / self.fs
 
-    def process(self, signal: np.ndarray) -> tuple[Any, np.ndarray, list[Any]]:
+    def process(self, signal: np.ndarray) -> tuple[np.ndarray, np.ndarray, list[Any]]:
         """DESCRIPTION
 
         Args:
@@ -82,7 +82,7 @@ def process(self, signal: np.ndarray) -> tuple[Any, np.ndarray, list[Any]]:
             np.convolve(padded_signal**2, self.window, mode="valid") / self.win_len
             + self.eps
         )
-        comp_ratios: list = []
+        comp_ratios: list[float] = []
         curr_comp: float = 1.0
         for rms_i in rms:
             if rms_i > self.threshold:
diff --git a/clarity/enhancer/dsp/filter.py b/clarity/enhancer/dsp/filter.py
index f402c069c..f6e9dd8be 100644
--- a/clarity/enhancer/dsp/filter.py
+++ b/clarity/enhancer/dsp/filter.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import numpy as np
 import torch
 import torch.nn.functional as F
@@ -7,12 +9,16 @@
 
 
 class AudiometricFIR(nn.Module):
-    def __init__(self, sr=44100, nfir=220, device=None):
+    def __init__(
+        self, sr: int = 44100, nfir: int = 220, device: str | None = None
+    ) -> None:
         super().__init__()
-        if device is not None:
+        # if device is not None: <-- this line was previously wrong!
+        if device is None:
             self.device = "cuda" if torch.cuda.is_available() else "cpu"
         else:
             self.device = device
+
         self.window_size = nfir + 1
         self.padding = nfir // 2
 
@@ -51,7 +57,7 @@ def __init__(self, sr=44100, nfir=220, device=None):
             )
         )
 
-    def forward(self, x):
+    def forward(self, x: torch.Tensor) -> torch.Tensor:
         amp = torch.pow(10, torch.abs(self.amp) / 20.0)
         amp = torch.cat((torch.cat((amp[:1], amp)), amp[-1:]))
         y = amp[self.interval_idx]
diff --git a/clarity/enhancer/gha/audiogram.py b/clarity/enhancer/gha/audiogram.py
index 9ad48e7c8..ad2b84bfe 100644
--- a/clarity/enhancer/gha/audiogram.py
+++ b/clarity/enhancer/gha/audiogram.py
@@ -1,7 +1,13 @@
+from __future__ import annotations
+
 from dataclasses import dataclass
+from typing import TYPE_CHECKING
 
 import numpy as np
 
+if TYPE_CHECKING:
+    from numpy import ndarray
+
 
 @dataclass
 class Audiogram:
@@ -12,7 +18,7 @@ class Audiogram:
     cfs: np.ndarray
 
     @property
-    def severity(self):
+    def severity(self) -> list[str]:
         """Categorise HL severity level for the audiogram.
 
         Note that this categorisation is different from that of the British Society of
@@ -30,7 +36,11 @@ def severity(self):
         """
         # calculate mean hearing loss between 2 & 8 kHz
         impairment_freqs = np.logical_and(2000 <= self.cfs, self.cfs <= 8000)
-        severity_levels = [None, None]
+
+        if not np.any(impairment_freqs):
+            return ["NOTHING", "NOTHING"]
+
+        severity_levels = ["NOTHING", "NOTHING"]
         for i, levels in enumerate([self.levels_l, self.levels_r]):
             impairment_degree = np.mean(levels[impairment_freqs])
             if impairment_degree > 56:
@@ -44,7 +54,7 @@ def severity(self):
             severity_levels[i] = severity_level
         return severity_levels
 
-    def select_subset_of_cfs(self, selected_cfs):
+    def select_subset_of_cfs(self, selected_cfs: ndarray | list[int]) -> Audiogram:
         """Make a new audiogram using a given subset of centre freqs.
 
         Note, any selected_cfs that do not exist in the original audiogram
diff --git a/clarity/enhancer/gha/gainrule_camfit.py b/clarity/enhancer/gha/gainrule_camfit.py
index b0fd474b5..70e9abe4c 100644
--- a/clarity/enhancer/gha/gainrule_camfit.py
+++ b/clarity/enhancer/gha/gainrule_camfit.py
@@ -1,15 +1,28 @@
+from __future__ import annotations
+
 import copy
 import logging
+from typing import TYPE_CHECKING, Any
 
 import numpy as np
 from scipy.interpolate import interp1d
 
+if TYPE_CHECKING:
+    from numpy import ndarray
+
+    from clarity.enhancer.gha.audiogram import Audiogram
 
-def compute_proportion_overlap(a1, a2, b1, b2):
+
+def compute_proportion_overlap(
+    a1: float,
+    a2: float,
+    b1: float,
+    b2: float,
+) -> float:
     """Compute proportion of overlap of two ranges.
 
     For ranges (a1, a2) and (b1, b2), express the extent of the overlap
-    in the range as a proportion of the extent of range (b1, b2).add()
+    in the range as a proportion of the extent of range (b1, b2)
 
     e.g (4, 9) and (6, 15) -> overlap (6,9), so proportion is (9-6)/(15-6)
 
@@ -17,13 +30,12 @@ def compute_proportion_overlap(a1, a2, b1, b2):
     left = max(a1, b1)
     right = min(a2, b2)
     if left > right:
-        overlap = 0.0
-    else:
-        overlap = float(right - left) / (b2 - b1)
-    return overlap
+        return 0.0
+
+    return float(right - left) / (b2 - b1)
 
 
-def isothr(vsDesF):
+def isothr(vsDesF: list[int] | ndarray) -> ndarray:
     """Calculate conversion factors of HL thresholds to SPL thresholds.
 
     Translation of OpenMHA isothr.m. Calculates conversion factors of HL
@@ -128,7 +140,7 @@ def isothr(vsDesF):
     return vIsoThrDB
 
 
-def freq_interp_sh(f_in, y_in, f):
+def freq_interp_sh(f_in: ndarray, y_in: ndarray, f: list[float] | ndarray) -> ndarray:
     """Linear interpolation on logarithmic frequency scale.
 
     Has samples and hold on edges.
@@ -162,7 +174,12 @@ def freq_interp_sh(f_in, y_in, f):
     return y
 
 
-def gains(compr_thr_inputs, compr_thr_gains, compression_ratios, levels):
+def gains(
+    compr_thr_inputs: list[int] | ndarray,
+    compr_thr_gains: list[int] | ndarray,
+    compression_ratios: list[float] | ndarray,
+    levels: list[float] | ndarray,
+) -> ndarray:
     """Based on OpenMHA gains subfunction of gainrule_camfit_compr.
 
     Args:
@@ -191,8 +208,12 @@ def gains(compr_thr_inputs, compr_thr_gains, compression_ratios, levels):
 
 
 def gainrule_camfit_linear(
-    audiogram, sFitmodel, noisegatelevels=45, noisegateslope=1, max_output_level=100
-):
+    audiogram: Audiogram,
+    sFitmodel: dict[str, Any],
+    noisegatelevels: int | list[int] = 45,
+    noisegateslope: int = 1,
+    max_output_level: int = 100,
+) -> dict[str, ndarray]:
     """Apply linear Cambridge rule for hearing aid fittings 'CAMFIT'.
 
     Based on OpenMHA gainrule_camfit_linear.m. Applies linear Cambridge rule for
@@ -305,13 +326,13 @@ def gainrule_camfit_linear(
 
 
 def gainrule_camfit_compr(
-    audiogram,
-    sFitmodel,
-    noisegatelevels=45,
-    noisegateslope=1,
-    level=0,
-    max_output_level=100,
-):
+    audiogram: Audiogram,
+    sFitmodel: dict[str, Any],
+    noisegatelevels: int | list[int] = 45,
+    noisegateslope: int = 1,
+    level: int = 0,
+    max_output_level: int = 100,
+) -> dict[str, ndarray]:
     """Applies compressive Cambridge rule for hearing aid fittings 'CAMFIT'.
 
     Translation of OpenMHA gainrule_camfit_compr.m.
@@ -456,7 +477,7 @@ def gainrule_camfit_compr(
         audiogram, sFitmodel, noisegatelevels, noisegateslope, max_output_level
     )
     insertion_gains = Gmid["insertion_gains"]
-    Gmid = Gmid["sGt"]
+    gmid_sgt = Gmid["sGt"]
 
     # Calculate compression ratios
     compression_ratio = np.zeros((np.size(frequencies), 2))
@@ -471,7 +492,7 @@ def gainrule_camfit_compr(
 
     for i, levels in enumerate([audiogram.levels_l, audiogram.levels_r]):
         if level != 0:
-            tmp = Lmid + Gmid[cr_idx, :, i].flatten() - Lmin - Gmin[:, i]
+            tmp = Lmid + gmid_sgt[cr_idx, :, i].flatten() - Lmin - Gmin[:, i]
         else:
             tmp = Lmid + insertion_gains[:, i] - Lmin - Gmin[:, i]
         idx = [i for i, x in enumerate(tmp < 13) if x]
diff --git a/clarity/enhancer/gha/gha_interface.py b/clarity/enhancer/gha/gha_interface.py
index 46b4b69c0..9689fea0f 100644
--- a/clarity/enhancer/gha/gha_interface.py
+++ b/clarity/enhancer/gha/gha_interface.py
@@ -66,6 +66,9 @@ def create_configured_cfgfile(
 
         if self.fs != 44100:
             logging.error("Current GHA configuration requires 44.1kHz sampling rate.")
+            raise ValueError(
+                "Current GHA configuration requires 44.1kHz sampling rate."
+            )
 
         cfg_template_file = pathlib.Path(cfg_template_file)
 
@@ -117,9 +120,7 @@ def process_files(self, infile_names, outfile_name, audiogram, listener=None):
         )
         formatted_sGt = format_gaintable(gaintable, noisegate_corr=True)
 
-        cfg_template = (
-            Path(__file__).parent / f"cfg_files/{self.cfg_file}_template.cfg",
-        )
+        cfg_template = Path(__file__).parent / f"cfg_files/{self.cfg_file}_template.cfg"
 
         # Merge CH1 and CH3 files. This is the baseline configuration.
         # CH2 is ignored.
@@ -137,7 +138,6 @@ def process_files(self, infile_names, outfile_name, audiogram, listener=None):
         )
         # Again, only need file name; must immediately close the unused file handle.
         os.close(fd_cfg)
-
         with open(cfg_filename, "w", encoding="utf-8") as f:
             f.write(
                 self.create_configured_cfgfile(
@@ -174,10 +174,13 @@ def process_files(self, infile_names, outfile_name, audiogram, listener=None):
         if not np.all(np.sum(abs(sig), axis=0)):
             raise ValueError("Channel empty.")
 
+        # Rewriting as floating point
         self.write_signal(outfile_name, sig, floating_point=True)
 
         logging.info("OpenMHA processing complete")
 
+    # TODO: MARKED FOR DEDUPLICATION
+    # Mirrors functionality that already exists in msbg_utils
     def read_signal(
         self,
         filename: str | Path,
@@ -218,30 +221,39 @@ def read_signal(
 
         return x
 
+    # TODO: MARKED FOR DEDUPLICATION
+    # Mirrors functionality that already exists in msbg_utils
     def write_signal(
-        self, filename: str | Path, x, floating_point: bool = True
+        self, filename: str | Path, x, floating_point: bool = True, strict: bool = False
     ) -> None:
         """Write a signal as fixed or floating point wav file."""
 
         if floating_point is False:
-            if self.test_nbits == 16:
-                subtype = "PCM_16"
-                # If signal is float and we want int16
-                x *= 32768
-                x = x.astype(np.dtype("int16"))
-                assert np.max(x) <= 32767 and np.min(x) >= -32768
-            elif self.test_nbits == 24:
-                subtype = "PCM_24"
-            else:
-                raise ValueError("test_nbits must be 16 or 24")
+            subtype = "PCM_16"
+            # If signal is float and we want int16
+            x = x * 32768
+            if strict and (np.max(x) > 32767 or np.min(x) < -32768):
+                raise ValueError("Signal out of range -1.0 to 1.0")
+            x = x.astype(np.dtype("int16"))
         else:
             subtype = "FLOAT"
 
         soundfile.write(filename, x, self.fs, subtype=subtype)
 
-    def create_HA_inputs(self, infile_names, merged_filename):
-        """Create input signal for baseline hearing aids."""
+    def create_HA_inputs(self, infile_names: list[str], merged_filename: str) -> None:
+        """Create input signal for baseline hearing aids.
+
+        The baseline hearing aid takes a 4-channel wav file as input. This is
+        constructed from the left and right signals of the front (CH1) and
+        rear (CH3) microphones that are available in the Clarity data.
 
+          Args:
+              infile_names (list[str]): Names of file to read
+              merged_file_name (str): Name of file to write
+
+          Raises:
+              ValueError: If input channel names are inconsistent
+        """
         if (infile_names[0][-5] != "1") or (infile_names[2][-5] != "3"):
             raise ValueError("HA-input signal error: channel mismatch!")
 
@@ -249,6 +261,7 @@ def create_HA_inputs(self, infile_names, merged_filename):
         signal_CH3 = self.read_signal(infile_names[2])
 
         merged_signal = np.zeros((len(signal_CH1), 4))
+
         # channel index 0 = front microphone on the left hearing aid
         merged_signal[:, 0] = signal_CH1[:, 0]
         # channel index 1 = front microphone on the right hearing aid
@@ -258,4 +271,6 @@ def create_HA_inputs(self, infile_names, merged_filename):
         # channel index 3 = rear microphone on the right hearing aid
         merged_signal[:, 3] = signal_CH3[:, 1]
 
-        self.write_signal(merged_filename, merged_signal, floating_point=True)
+        self.write_signal(
+            merged_filename, merged_signal, floating_point=True, strict=True
+        )
diff --git a/clarity/enhancer/gha/gha_utils.py b/clarity/enhancer/gha/gha_utils.py
index db581b6f4..931bef740 100644
--- a/clarity/enhancer/gha/gha_utils.py
+++ b/clarity/enhancer/gha/gha_utils.py
@@ -1,16 +1,28 @@
+from __future__ import annotations
+
 import copy
 import logging
 import re
+from typing import TYPE_CHECKING, Any
 
 import numpy as np
 from scipy.interpolate import interp1d
 
 from clarity.enhancer.gha.gainrule_camfit import gainrule_camfit_compr
 
+if TYPE_CHECKING:
+    from numpy import ndarray
+
+    from clarity.enhancer.gha.audiogram import Audiogram
+
 
 def get_gaintable(
-    audiogram, noisegatelevels, noisegateslope, cr_level, max_output_level
-):
+    audiogram: Audiogram,
+    noisegatelevels: list[int],
+    noisegateslope: int,
+    cr_level: int,
+    max_output_level: int,
+) -> dict[str, Any]:
     """Compute a gaintable for a given audiogram.
 
     Replaces MATLAB GUI interface of original OpenMHA software for
@@ -31,7 +43,7 @@ def get_gaintable(
     """
     # Initialise parameters
     num_channels = 2  # only configured for two channels
-    sFitmodel = {}
+    sFitmodel: dict[str, Any] = {}
 
     # Fixed centre frequencies for amplification bands
     sFitmodel["frequencies"] = [
@@ -96,7 +108,10 @@ def get_gaintable(
     return sGt
 
 
-def format_gaintable(gaintable, noisegate_corr=True):
+def format_gaintable(
+    gaintable: dict[str, Any],
+    noisegate_corr: bool = True,
+) -> str:
     """
     Format gaintable for insertion into cfg file as long string.
 
@@ -135,8 +150,12 @@ def format_gaintable(gaintable, noisegate_corr=True):
 
 
 def multifit_apply_noisegate(
-    sGt, sFit_model_frequencies, sFit_model_levels, noisegate_level, noisegate_slope
-):
+    sGt: ndarray,
+    sFit_model_frequencies: list[float | int],
+    sFit_model_levels: ndarray,
+    noisegate_level: ndarray,
+    noisegate_slope: ndarray,
+) -> dict[str, ndarray]:
     """Apply noisegate.
 
     Based on OpenMHA subfunction of libmultifit.m.
@@ -149,7 +168,7 @@ def multifit_apply_noisegate(
         noisegate_slope (ndarray): determines slope below compression threshold
 
     Returns:
-        ndarray: Noise signal
+        dict: Noise signal
 
     """
 
@@ -165,7 +184,7 @@ def multifit_apply_noisegate(
                 sFit_model_levels[idx] - noisegate_level[kf, i]
             ) * noisegate_slope[kf, i] + gain_noisegate
 
-    output = {}
+    output: dict[str, Any] = {}
     output["sGt"] = {}
     output["sGt"] = sGt
     output["noisegatelevel"] = noisegate_level
diff --git a/clarity/enhancer/nalr.py b/clarity/enhancer/nalr.py
index bac9c49aa..febd2fe9e 100644
--- a/clarity/enhancer/nalr.py
+++ b/clarity/enhancer/nalr.py
@@ -1,120 +1,19 @@
 from __future__ import annotations
 
-import math
+from typing import TYPE_CHECKING
 
 import numpy as np
 import scipy
 import scipy.signal
 
+from clarity.evaluator.msbg.msbg_utils import firwin2
 
-def firwin2(
-    n: int,
-    f: np.ndarray,
-    a: np.ndarray,
-    window: str | float | None = None,
-    antisymmetric: bool | None = None,  # pylint: disable=unused-argument
-) -> np.ndarray:
-    """FIR filter design using the window method.
-    Partial implementation of scipy firwin2 but using our own MATLAB-derived fir2.
-
-    Args:
-        n (int): The number of taps in the FIR filter.
-        f (ndarray): The frequency sampling points. 0.0 to 1.0 with 1.0 being Nyquist.
-        a (ndarray): The filter gains at the frequency sampling points.
-        window (string or (string, float), optional): See scipy.firwin2 (default: None)
-        _antisymmetric (bool, optional): Unused but present to main compatibility
-            with scipy firwin2.
-    Returns:
-        np.ndarray:  The FIR filter coefficients as a 1-D array of length n.
-    """
-    window_shape = None
-    window_type: str | float | None
-    window_param: str | float
-    if isinstance(window, tuple):
-        window_type, window_param = window if window is not None else (None, 0)
-    else:
-        window_type = window
-
-    order = n - 1
-
-    if window_type == "kaiser":
-        window_shape = scipy.signal.kaiser(n, window_param)
-
-    if window_shape is None:
-        b, _ = fir2(order, f, a)
-    else:
-        b, _ = fir2(order, f, a, window_shape)
-
-    return b
-
-
-def fir2(
-    nn: int, ff: np.ndarray, aa: np.ndarray, npt: int | None = None
-) -> tuple[np.ndarray, int]:
-    """FIR arbitrary shape filter design using the frequency sampling method.
-    Translation of MATLAB fir2.
-
-    Args:
-        nn (int): Order
-        ff (np.ndarray): Frequency breakpoints (0 < F < 1) where 1 is Nyquist rate.
-                        First and last elements must be 0 and 1 respectively
-        aa (np.ndarray): Magnitude breakpoints
-        npt (int, optional): Number of points for freq response interpolation
-            (default: max (smallest power of 2 greater than nn, 512))
-
-    Returns:
-        np.ndarray: nn + 1 filter coefficients
-    """
-    # Work with filter length instead of filter order
-    nn += 1
-
-    if npt is None:
-        npt = 2.0 ** np.ceil(math.log(nn) / math.log(2)) if nn >= 1024 else 512
-        wind = scipy.signal.hamming(nn)
-    else:
-        wind = npt
-        npt = 2.0 ** np.ceil(math.log(nn) / math.log(2)) if nn >= 1024 else 512
-    lap = int(np.fix(npt / 25))
-
-    nbrk = max(len(ff), len(aa))
-
-    ff[0] = 0
-    ff[nbrk - 1] = 1
-
-    H = np.zeros(npt + 1)
-    nint: int = nbrk - 1
-    df = np.diff(ff, n=1)
-
-    npt += 1
-    nb: int = 0
-    H[0] = aa[0]
-
-    for i in np.arange(nint):
-        if df[i] == 0:
-            nb = int(np.ceil(nb - lap / 2))
-            ne = nb + lap - 1
-        else:
-            ne = int(np.fix(ff[i + 1] * npt)) - 1
-
-        j = np.arange(nb, ne + 1)
-        inc: float | np.ndarray = 0.0 if nb == ne else (j - nb) / (ne - nb)
-        H[nb : (ne + 1)] = (inc * aa[i + 1]) + ((1 - inc) * aa[i])
-        nb = ne + 1
-
-    dt: float = 0.5 * (nn - 1)
-    rad = -dt * 1j * math.pi * np.arange(0, npt) / (npt - 1)
-    H = H * np.exp(rad)
-
-    H = np.concatenate((H, H[npt - 2 : 0 : -1].conj()))
-    ht = np.real(np.fft.ifft(H))
-
-    b = ht[0:nn] * wind
-
-    return b, 1
+if TYPE_CHECKING:
+    from numpy import ndarray
 
 
 class NALR:
-    def __init__(self, nfir: int, fs: int):
+    def __init__(self, nfir: int, fs: int) -> None:
         """
         Args:
             nfir: Order of the NAL-R EQ filter and the matching delay
@@ -131,7 +30,7 @@ def __init__(self, nfir: int, fs: int):
         self.delay = np.zeros(nfir + 1)
         self.delay[nfir // 2] = 1.0
 
-    def hl_interp(self, hl: np.ndarray, cfs: np.ndarray):
+    def hl_interp(self, hl: np.ndarray, cfs: np.ndarray) -> ndarray:
         try:
             hl_interpf = scipy.interpolate.interp1d(cfs, hl)
         except ValueError as exception:
@@ -144,7 +43,7 @@ def build(
         self,
         hl: np.ndarray,
         cfs: np.ndarray = None,
-    ):
+    ) -> tuple[ndarray, ndarray]:
         """
         Args:
             hl: hearing thresholds at [250, 500, 1000, 2000, 4000, 6000] Hz
@@ -190,7 +89,7 @@ def build(
             nalr = self.delay.copy()
         return nalr, self.delay
 
-    def apply(self, nalr: np.ndarray, wav: np.ndarray):
+    def apply(self, nalr: np.ndarray, wav: np.ndarray) -> ndarray:
         """
         Args:
             nalr: built NAL-R FIR filter
diff --git a/clarity/evaluator/haaqi/haaqi.py b/clarity/evaluator/haaqi/haaqi.py
index 5efcd3ad7..6c78d98ee 100644
--- a/clarity/evaluator/haaqi/haaqi.py
+++ b/clarity/evaluator/haaqi/haaqi.py
@@ -1,11 +1,16 @@
 """Matlab's haaqi version 1 to python version."""
+import logging
+
 import numpy as np
 
 from clarity.evaluator.haspi import eb
+from clarity.utils.signal_processing import compute_rms
 
 # pylint: disable=too-many-arguments
 # pylint: disable=too-many-locals
 
+logger = logging.getLogger(__name__)
+
 
 def haaqi_v1(
     reference: np.ndarray,
@@ -14,7 +19,7 @@ def haaqi_v1(
     processed_freq: int,
     hearing_loss: np.ndarray,
     equalisation: int,
-    level1: int = 65,
+    level1: float = 65.0,
     silence_threshold: float = 2.5,
     add_noise: float = 0.0,
     segment_covariance: int = 16,
@@ -159,8 +164,28 @@ def compute_haaqi(
     audiogram: np.ndarray,
     audiogram_frequencies: np.ndarray,
     sample_rate: int,
+    equalisation: int = 1,
+    level1: float = 65.0,
+    scale_reference: bool = True,
 ) -> float:
-    """Compute HAAQI metric"""
+    """Compute HAAQI metric
+
+    Args:
+        processed_signal (np.ndarray): Output signal with noise, distortion, HA gain,
+            and/or processing.
+        reference_signal (np.ndarray): Input reference speech signal with no noise
+            or distortion. If a hearing loss is specified, NAL-R equalization
+            is optional
+        audiogram (np.ndarray): Vector of hearing loss at the audiogram_frequencies
+        audiogram_frequencies (np.ndarray): Audiogram frequencies
+        sample_rate (int): Sample rate in Hz.
+        equalisation (int): hearing loss equalization mode for reference signal:
+            1 = no EQ has been provided, the function will add NAL-R
+            2 = NAL-R EQ has already been added to the reference signal
+            Defaults to 1.
+        level1 (float): Reference level in dB SPL. Defaults to 65.0.
+        scale_reference (bool): Scale the reference signal to RMS=1. Defaults to True.
+    """
 
     haaqi_audiogram_frequencies = [250, 500, 1000, 2000, 4000, 6000]
     audiogram_adjusted = np.array(
@@ -170,12 +195,24 @@ def compute_haaqi(
             if audiogram_frequencies[i] in haaqi_audiogram_frequencies
         ]
     )
+
+    if len(reference_signal) == 0:
+        if len(processed_signal) == 0:
+            # No scoring if no music
+            return 1.0
+        logger.error("If `Reference` is empty, `Processed` must be empty as well")
+        return 0.0
+
+    if scale_reference:
+        reference_signal /= compute_rms(reference_signal)
+
     score, _, _, _ = haaqi_v1(
         reference=reference_signal,
         reference_freq=sample_rate,
         processed=processed_signal,
         processed_freq=sample_rate,
         hearing_loss=audiogram_adjusted,
-        equalisation=1,
+        equalisation=equalisation,
+        level1=level1,
     )
     return score
diff --git a/clarity/evaluator/haspi/eb.py b/clarity/evaluator/haspi/eb.py
index 34872be45..eea9d8d03 100644
--- a/clarity/evaluator/haspi/eb.py
+++ b/clarity/evaluator/haspi/eb.py
@@ -1,4 +1,5 @@
 """Module for HASPI, HASQI, HAAQI EBs"""
+# pylint: disable=import-error
 import logging
 
 import numpy as np
@@ -115,12 +116,10 @@ def ear_model(
         attn_ihc_x,
     ] = loss_parameters(hearing_loss_x, _center_freq)
 
-    # Parameters for the control filter bank
-    hl_max = [100, 100, 100, 100, 100, 100]
     # Compute center frequencies for the control
     _center_freq_control = center_frequency(nchan, shift)
     # Maximum BW for the control
-    _, bandwidth_1, _, _, _ = loss_parameters(hl_max, _center_freq_control)
+    _, bandwidth_1, _, _, _ = loss_parameters(np.full(6, 100), _center_freq_control)
 
     # Input signal adjustments
     # Convert the signals to 24 kHz sampling rate.
@@ -143,9 +142,9 @@ def ear_model(
     if itype == 1:
         nfir = 140  # Length in samples of the FIR NAL-R EQ filter (24-kHz rate)
         enhancer = NALR(nfir, freq_sample)
-        aud = [250, 500, 1000, 2000, 4000, 6000]
+        aud = np.array([250, 500, 1000, 2000, 4000, 6000])
         nalr_fir, _ = enhancer.build(hearing_loss, aud)
-        reference_24hz = convolve(reference_24hz, nalr_fir)  # Apply the NAL-R filter
+        reference_24hz = enhancer.apply(nalr_fir, reference_24hz)
         reference_24hz = reference_24hz[nfir : nfir + nsamp]
 
     # Cochlear model
@@ -876,9 +875,6 @@ def env_compress_basilar_membrane(
     Two-tone suppression added 22 August 2008.
     Translated from MATLAB to Python by Zuzanna Podwinska, March 2022.
     """
-    # Initialize the compression parameters
-    threshold_high = 100
-
     # Convert the control envelope to dB SPL
     logenv = np.maximum(control, small)
     logenv = level1 + 20 * np.log10(logenv)
@@ -1341,10 +1337,8 @@ def mel_cepstrum_correlation(reference, distorted, threshold, addnoise):
     nbasis = 6  # Number of cepstral coefficients to be used
     freq = np.arange(nbasis)
     k = np.arange(nbands)
-    mel_cepstral = np.zeros((nbands, nbasis))
-    for n in range(nbasis):
-        basis = np.cos(k * float(freq[n]) * np.pi / float(nbands - 1))
-        mel_cepstral[:, n] = basis / np.linalg.norm(basis)
+    basis = np.cos(np.outer(k, freq) * np.pi / float(nbands - 1))
+    mel_cepstral = basis / np.linalg.norm(basis, axis=0)
 
     # Find the segments that lie sufficiently above the quiescent rate
     reference_linear = 10 ** (
@@ -1374,32 +1368,27 @@ def mel_cepstrum_correlation(reference, distorted, threshold, addnoise):
 
     # Compute the mel cepstrum coefficients using only those segments
     # above threshold
-    reference_cep = np.zeros((nbasis, nsamp))  # Input
-    processed_cep = np.zeros((nbasis, nsamp))  # Output
-    for n in range(nsamp):
-        for k in range(nbasis):
-            reference_cep[k, n] = np.sum(ref[:, n] * mel_cepstral[:, k])
-            processed_cep[k, n] = np.sum(proc[:, n] * mel_cepstral[:, k])
+
+    reference_cep = np.dot(mel_cepstral.T, ref)
+    processed_cep = np.dot(mel_cepstral.T, proc)
 
     # Remove the average value from the cepstral coefficients. The
     # cross-correlation thus becomes a cross-covariance, and there
     # is no effect of the absolute signal level in dB.
-    for k in range(nbasis):
-        reference_cep[k, :] = reference_cep[k, :] - np.mean(reference_cep[k, :], axis=0)
-        processed_cep[k, :] = processed_cep[k, :] - np.mean(processed_cep[k, :], axis=0)
+    reference_cep -= np.mean(reference_cep, axis=1, keepdims=True)
+    processed_cep -= np.mean(processed_cep, axis=1, keepdims=True)
 
     # Normalized cross-correlations between the time-varying cepstral coeff
-    individual_cepstral_correlations = np.zeros(nbasis)  # Input vs output
+    # individual_cepstral_correlations = np.zeros(nbasis)  # Input vs output
     small = 1.0e-30
-    for k in range(nbasis):
-        xsum = np.sum(reference_cep[k, :] ** 2)
-        ysum = np.sum(processed_cep[k, :] ** 2)
-        if (xsum < small) or (ysum < small):
-            individual_cepstral_correlations[k] = 0.0
-        else:
-            individual_cepstral_correlations[k] = np.abs(
-                np.sum(reference_cep[k, :] * processed_cep[k, :]) / np.sqrt(xsum * ysum)
-            )
+    xsum = np.sum(reference_cep**2, axis=1)
+    ysum = np.sum(processed_cep**2, axis=1)
+    mask = (xsum < small) | (ysum < small)
+    individual_cepstral_correlations = np.zeros(nbasis)
+    individual_cepstral_correlations[~mask] = np.abs(
+        np.sum(reference_cep[~mask] * processed_cep[~mask], axis=1)
+        / np.sqrt(xsum[~mask] * ysum[~mask])
+    )
 
     # Figure of merit is the average of the cepstral correlations, ignoring
     # the first (average spectrum level).
@@ -1459,10 +1448,8 @@ def melcor9(
     # Mel cepstrum basis functions (mel cepstrum because of auditory bands)
     freq = np.arange(n_cepstral_coef)
     k = np.arange(nbands)
-    cepm = np.zeros((nbands, n_cepstral_coef))
-    for n in range(n_cepstral_coef):
-        basis = np.cos(k * float(freq[n]) * np.pi / float(nbands - 1))
-        cepm[:, n] = basis / np.linalg.norm(basis)
+    basis = np.cos(np.outer(k, freq) * np.pi / (nbands - 1))
+    cepm = basis / np.linalg.norm(basis, axis=0, keepdims=True)
 
     # Find the segments that lie sufficiently above the quiescent rate
     # Convert envelope dB to linear (specific loudness)
@@ -1502,24 +1489,16 @@ def melcor9(
     _distorted = distorted[:, index]
 
     # Add the low-level noise to the envelopes
-    _reference = _reference + add_noise * np.random.standard_normal(_reference.shape)
-    _distorted = _distorted + add_noise * np.random.standard_normal(_distorted.shape)
+    _reference += add_noise * np.random.standard_normal(_reference.shape)
+    _distorted += add_noise * np.random.standard_normal(_distorted.shape)
 
     # Compute the mel cepstrum coefficients using only those segments
     # above threshold
-    reference_cep = np.zeros((n_cepstral_coef, segments_above_threshold))  # Input
-    distorted_cep = np.zeros((n_cepstral_coef, segments_above_threshold))  # Output
-    for n in range(segments_above_threshold):
-        for k in range(n_cepstral_coef):
-            reference_cep[k, n] = np.sum(_reference[:, n] * cepm[:, k])
-            distorted_cep[k, n] = np.sum(_distorted[:, n] * cepm[:, k])
+    reference_cep = np.dot(cepm.T, _reference[:, :segments_above_threshold])
+    distorted_cep = np.dot(cepm.T, _distorted[:, :segments_above_threshold])
 
-    # Remove the average value from the cepstral coefficients. The
-    # cross-correlation thus becomes a cross-covariance, and there
-    # is no effect of the absolute signal level in dB.
-    for k in range(n_cepstral_coef):
-        reference_cep[k, :] = reference_cep[k, :] - np.mean(reference_cep[k, :], axis=0)
-        distorted_cep[k, :] = distorted_cep[k, :] - np.mean(distorted_cep[k, :], axis=0)
+    reference_cep -= np.mean(reference_cep, axis=1, keepdims=True)
+    distorted_cep -= np.mean(distorted_cep, axis=1, keepdims=True)
 
     # Envelope sampling parameters
     sampling_freq = 1000.0 / (0.5 * segment_size)  # Envelope sampling frequency in Hz
@@ -1562,36 +1541,16 @@ def melcor9(
         distorted_cep,
     )
 
-    # Average over the  modulation filters and basis functions 2 - 6
-    for m in range(n_modulation_filter_bands):
-        for j in range(1, n_cepstral_coef):
-            mel_cepstral_average += mel_cepstral_cross_covar[m, j]
-
-    mel_cepstral_average = mel_cepstral_average / (
-        n_modulation_filter_bands * (n_cepstral_coef - 1)
-    )
-
-    # Average over the four lower modulation filters
-    for m in range(4):
-        for j in range(1, n_cepstral_coef):
-            mel_cepstral_low += mel_cepstral_cross_covar[m, j]
-
-    mel_cepstral_low = mel_cepstral_low / (4 * (n_cepstral_coef - 1))
-
-    #  Average over the four upper modulation filters
-    for m in range(4, 8):
-        for j in range(1, n_cepstral_coef):
-            mel_cepstral_high += mel_cepstral_cross_covar[m, j]
+    mel_cepstral_average = np.sum(mel_cepstral_cross_covar[:, 1:], axis=(0, 1))
+    mel_cepstral_average /= n_modulation_filter_bands * (n_cepstral_coef - 1)
 
-    mel_cepstral_high = mel_cepstral_high / (4 * (n_cepstral_coef - 1))
+    mel_cepstral_low = np.sum(mel_cepstral_cross_covar[:4, 1:])
+    mel_cepstral_low /= 4 * (n_cepstral_coef - 1)
 
-    # Average each modulation frequency over the basis functions
-    for m in range(n_modulation_filter_bands):
-        ave = 0
-        for j in range(1, n_cepstral_coef):
-            ave += mel_cepstral_cross_covar[m, j]
+    mel_cepstral_high = np.sum(mel_cepstral_cross_covar[4:8, 1:])
+    mel_cepstral_high /= 4 * (n_cepstral_coef - 1)
 
-        mel_cepstral_modulation[m] = ave / (n_cepstral_coef - 1)
+    mel_cepstral_modulation = np.mean(mel_cepstral_cross_covar[:, 1:], axis=1)
 
     return (
         mel_cepstral_average,
@@ -1631,25 +1590,24 @@ def melcor9_crosscovmatrix(b, nmod, nbasis, nsamp, nfir, reference_cep, processe
 
     # Compute the cross-covariance matrix
     cross_covariance_matrix = np.zeros((nmod, nbasis))
+
     for m in range(nmod):
-        for j in range(nbasis):
-            #  Index j gives the input reference band
-            x_j = reference[m, j]  # Input freq band j, modulation freq m
-            x_j = x_j - np.mean(x_j)
-            reference_sum = np.sum(x_j**2)
-
-            # Processed signal band
-            y_j = processed[m, j]  # Input freq band j, modulation freq m
-            y_j = y_j - np.mean(y_j)
-            processed_sum = np.sum(y_j**2)
-
-            # Cross-correlate the reference and processed signals
-            if (reference_sum < small) or (processed_sum < small):
-                cross_covariance_matrix[m, j] = 0
-            else:
-                cross_covariance_matrix[m, j] = np.abs(np.sum(x_j * y_j)) / np.sqrt(
-                    reference_sum * processed_sum
-                )
+        # Input freq band j, modulation freq m
+        x_j = reference[m]
+        x_j -= np.mean(x_j, axis=1, keepdims=True)
+        reference_sum = np.sum(x_j**2, axis=1)
+
+        # Processed signal band
+        y_j = processed[m]
+        y_j -= np.mean(y_j, axis=1, keepdims=True)
+        processed_sum = np.sum(y_j**2, axis=1)
+
+        xy = np.sum(x_j * y_j, axis=1)
+        mask = (reference_sum < small) | (processed_sum < small)
+        cross_covariance_matrix[m, ~mask] = np.abs(xy[~mask]) / np.sqrt(
+            reference_sum[~mask] * processed_sum[~mask]
+        )
+
     return cross_covariance_matrix
 
 
@@ -1986,11 +1944,14 @@ def ave_covary2(
     # Default cutoff frequencies in Hz
     if freq_cutoff is None:
         freq_cutoff = 1000 * np.array([1.5, 2.0, 2.5, 3.0, 3.5, 4.0])
-    fsync = np.zeros((6, n_channels))  # Array of filter freq resp vs band center freq
-    for n in range(6):
-        fc2p = freq_cutoff[n] ** (2 * lp_filter_order[n])
-        freq2p = _center_freq ** (2 * lp_filter_order[n])
-        fsync[n, :] = np.sqrt(fc2p / (fc2p + freq2p))
+
+    fc2p = (
+        np.atleast_2d(freq_cutoff ** (2 * lp_filter_order)).repeat(n_channels, axis=0).T
+    )
+    freq2p = _center_freq ** (
+        2 * np.atleast_2d(lp_filter_order).repeat(n_channels, axis=0).T
+    )
+    fsync = np.sqrt(fc2p / (fc2p + freq2p))
 
     # Find the segments that lie sufficiently above the threshold.
     # Convert squared amplitude to dB envelope
diff --git a/clarity/evaluator/msbg/audiogram.py b/clarity/evaluator/msbg/audiogram.py
index eaa8507c6..52cfc00e2 100644
--- a/clarity/evaluator/msbg/audiogram.py
+++ b/clarity/evaluator/msbg/audiogram.py
@@ -1,4 +1,5 @@
 """Supplied dataclass to represent a monaural audiogram"""
+from __future__ import annotations
 
 from dataclasses import dataclass
 
@@ -40,7 +41,7 @@ class Audiogram:
     cfs: np.ndarray = np.array(DEFAULT_CLARITY_CFS)
 
     @property
-    def severity(self):
+    def severity(self) -> str:
         """Categorise HL severity level for the audiogram.
 
         Note that this categorisation is different from that of the British
@@ -55,21 +56,22 @@ def severity(self):
             str -- severity level, one of SEVERE, MODERATE, MILD, NOTHING
 
         """
-        # calculate mean hearing loss between 2 & 8 kHz
-        impairment_freqs = np.logical_and(2000 <= self.cfs, self.cfs <= 8000)
-        tmp = self.levels[impairment_freqs]
+        # calculate mean hearing loss between critical frequencies of 2 & 8 kHz
+        critical_freqs = np.logical_and(2000 <= self.cfs, self.cfs <= 8000)
+        critical_levels = self.levels[critical_freqs]
+        # Remove any None values
+        critical_levels = [x for x in critical_levels if x is not None]
         # Ignore any None values
-        impairment_degree = np.mean(tmp[tmp is not None])
+        impairment_degree = np.mean(critical_levels) if len(critical_levels) > 0 else 0
 
         if impairment_degree > 56:
-            severity_level = "SEVERE"
-        elif impairment_degree > 35:
-            severity_level = "MODERATE"
-        elif impairment_degree > 15:
-            severity_level = "MILD"
-        else:
-            severity_level = "NOTHING"
-        return severity_level
+            return "SEVERE"
+        if impairment_degree > 35:
+            return "MODERATE"
+        if impairment_degree > 15:
+            return "MILD"
+
+        return "NOTHING"
 
 
 # Reference processing: use to check levels between original and processed,
@@ -96,14 +98,3 @@ def severity(self):
     cfs=FULL_STANDARD_CFS,
     levels=np.array([19, 19, 28, 35, 40, 47, 52, 56, 58, 58, 63, 70, 75, 80, 80]),
 )
-
-if __name__ == "__main__":
-    # test code
-    audiogram = Audiogram(
-        cfs=np.array([1000, 3000, 10000]), levels=np.array([10, 50, 80])
-    )
-    print(audiogram, audiogram.severity)
-    print(Audiogram(cfs=np.array([3000]), levels=np.array([10])).severity)
-    print(Audiogram(cfs=np.array([3000]), levels=np.array([20])).severity)
-    print(Audiogram(cfs=np.array([3000]), levels=np.array([40])).severity)
-    print(Audiogram(cfs=np.array([3000]), levels=np.array([90])).severity)
diff --git a/clarity/evaluator/msbg/cochlea.py b/clarity/evaluator/msbg/cochlea.py
index 850b3b18f..d65382df9 100644
--- a/clarity/evaluator/msbg/cochlea.py
+++ b/clarity/evaluator/msbg/cochlea.py
@@ -7,6 +7,7 @@
 
 import numpy as np
 import scipy
+from numpy import ndarray
 from scipy import signal
 
 from clarity.evaluator.msbg.audiogram import Audiogram
@@ -46,8 +47,8 @@ class FilterBank:
 
 
 def compute_recruitment_parameters(
-    gtn_cf: np.ndarray, audiogram: Audiogram, catch_up: float
-) -> tuple[np.ndarray, np.ndarray]:
+    gtn_cf: ndarray, audiogram: Audiogram, catch_up: float
+) -> tuple[ndarray, ndarray]:
     """Compute parameters to be used in recruitment model.
 
     Computes expansion ratios for each gammatone filterbank channel and
@@ -87,13 +88,13 @@ def compute_recruitment_parameters(
 
 
 def gammatone_filterbank(
-    x: np.ndarray,
+    x: ndarray,
     ngamma: int,
     gtn_filters: FilterBank,
-    gtn_delays: np.ndarray,
+    gtn_delays: ndarray,
     start2poleHP: int,
     hp_filters: FilterBank,
-) -> np.ndarray:
+) -> ndarray:
     """Pass signal through gammatone filterbank.
 
     Args:
@@ -136,9 +137,7 @@ def gammatone_filterbank(
     return cochleagram
 
 
-def compute_envelope(
-    coch_sig: np.ndarray, erbn_cf: np.ndarray, fs: float
-) -> np.ndarray:
+def compute_envelope(coch_sig: ndarray, erbn_cf: ndarray, fs: int | float) -> ndarray:
     """Obtain signal envelope.
 
     Envelope computed using full-wave rectification and low-pass filter
@@ -152,7 +151,6 @@ def compute_envelope(
         ndarray: signal envelope
 
     """
-
     envelope = np.zeros(coch_sig.shape)
     n_chans = coch_sig.shape[0]
     for ixch in np.arange(0, n_chans):
@@ -175,12 +173,12 @@ def compute_envelope(
 
 
 def recruitment(
-    coch_sig: np.ndarray,
-    envelope: np.ndarray,
-    SPL_equiv_0dB: float,
-    expansion_ratios: np.ndarray,
-    eq_loud_db: np.ndarray,
-) -> np.ndarray:
+    coch_sig: ndarray,
+    envelope: ndarray,
+    SPL_equiv_0dB: int | float,
+    expansion_ratios: ndarray,
+    eq_loud_db: ndarray,
+) -> ndarray:
     """Simulate loudness recruitment.
 
     Args:
@@ -226,7 +224,7 @@ class Cochlea:
 
     def __init__(
         self, audiogram: Audiogram, catch_up_level: float = 105.0, fs: float = 44100.0
-    ):
+    ) -> None:
         """Cochlea constructor.
 
         Args:
@@ -242,7 +240,7 @@ def __init__(
 
         # Compute severity level and set parameters accordingly
         severity_level = self.audiogram.severity
-        self.gtfbank_params = read_gtf_file(
+        self.gtfbank_params: dict[str, Any] = read_gtf_file(
             f"msbg_hparams/{HL_PARAMS[severity_level]['gtfbank_file']}.json"
         )
         self.cf_expansion, self.eq_loud_db_catch_up = compute_recruitment_parameters(
@@ -257,7 +255,7 @@ def __init__(
 
         logging.info("Severity level - %s", severity_level)
 
-    def simulate(self, coch_sig: np.ndarray, equiv_0dB_file_SPL: float) -> np.ndarray:
+    def simulate(self, coch_sig: ndarray, equiv_0dB_file_SPL: int | float) -> ndarray:
         """Pass a signal through the cochlea.
 
         Args:
diff --git a/clarity/evaluator/msbg/msbg.py b/clarity/evaluator/msbg/msbg.py
index fd00425ed..f5655089f 100644
--- a/clarity/evaluator/msbg/msbg.py
+++ b/clarity/evaluator/msbg/msbg.py
@@ -1,11 +1,15 @@
 """Implementation of the MSBG hearing loss model."""
+from __future__ import annotations
+
 import logging
 import math
 
 import numpy as np
 import scipy
+from numpy import float64, ndarray
 from scipy.signal import firwin, lfilter
 
+from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.cochlea import Cochlea
 from clarity.evaluator.msbg.msbg_utils import (
     DF_ED,
@@ -29,7 +33,11 @@ class Ear:
     """Representation of a pairs of ears."""
 
     def __init__(
-        self, src_pos="ff", sample_frequency: float = 44100.0, equiv_0db_spl=100, ahr=20
+        self,
+        src_pos: str = "ff",
+        sample_frequency: int | float = 44100.0,
+        equiv_0db_spl: int = 100,
+        ahr: int = 20,
     ) -> None:
         """
         Constructor for the Ear class.
@@ -43,9 +51,9 @@ def __init__(
         self.src_correction = self.get_src_correction(src_pos)
         self.equiv_0db_spl = equiv_0db_spl
         self.ahr = ahr
-        self.cochlea = None
+        self.cochlea: Cochlea | None = None
 
-    def set_audiogram(self, audiogram):
+    def set_audiogram(self, audiogram: Audiogram) -> None:
         """Set the audiogram to be used."""
         if np.max(audiogram.levels[audiogram.levels is not None]) > 80:
             logging.warning(
@@ -55,7 +63,7 @@ def set_audiogram(self, audiogram):
         self.cochlea = Cochlea(audiogram=audiogram)
 
     @staticmethod
-    def get_src_correction(src_pos: str) -> np.array:
+    def get_src_correction(src_pos: str) -> ndarray:
         """Select relevant external field to eardrum correction.
 
         Args:
@@ -78,8 +86,11 @@ def get_src_correction(src_pos: str) -> np.array:
 
     @staticmethod
     def src_to_cochlea_filt(
-        ip_sig: np.ndarray, src_correction: str, sample_frequency: int, backward=False
-    ) -> np.ndarray:
+        ip_sig: ndarray,
+        src_correction: ndarray,
+        sample_frequency: int,
+        backward: bool = False,
+    ) -> ndarray:
         """Simulate middle and outer ear transfer functions.
 
         Made more general, Mar2012, to include diffuse field as well as ITU reference
@@ -91,7 +102,9 @@ def src_to_cochlea_filt(
 
         Args:
             ip_sig (ndarray): signal to process
-            src_correction (str): correction to make for src position
+            src_correction (np.ndarray): correction to make for src position as an array
+                returned by get_src_correction(src_pos) where src_pos is one of ff, df
+                or ITU
             sample_frequency (int): sampling frequency
             backward (bool, optional): if true then cochlea to src (default: False)
 
@@ -129,11 +142,13 @@ def src_to_cochlea_filt(
 
         return op_sig
 
-    def make_calibration_signal(self, ref_rms_db):
+    def make_calibration_signal(
+        self, ref_rms_db: int | float64
+    ) -> tuple[ndarray, ndarray]:
         """Add the calibration signal to the start of the signal.
 
         Args:
-            ref_rms_db (ndarray): input signal
+            ref_rms_db (float): reference rms level in dB
 
         Returns:
             ndarray: the processed signal
@@ -159,7 +174,7 @@ def make_calibration_signal(self, ref_rms_db):
         )
 
     @staticmethod
-    def array_to_list(chans: np.ndarray) -> np.ndarray:
+    def array_to_list(chans: ndarray) -> list[ndarray]:
         """Convert signal into a list of 1-D arrays.
 
         Args:
@@ -172,7 +187,7 @@ def array_to_list(chans: np.ndarray) -> np.ndarray:
             chans = chans[..., np.newaxis]
         return [chans[:, i] for i in range(chans.shape[1])]
 
-    def process(self, signal: np.ndarray, add_calibration: bool = False) -> np.ndarray:
+    def process(self, signal: ndarray, add_calibration: bool = False) -> list[ndarray]:
         """Run the hearing loss simulation.
 
         Args:
@@ -190,6 +205,7 @@ def process(self, signal: np.ndarray, add_calibration: bool = False) -> np.ndarr
             logging.error(
                 "Warning: only a sampling frequency of 44.1kHz can be used by MSBG."
             )
+            raise ValueError("Invalid sampling frequency, valid value is 44100")
 
         logging.info("Processing {len(chans)} samples")
 
diff --git a/clarity/evaluator/msbg/msbg_utils.py b/clarity/evaluator/msbg/msbg_utils.py
index 0bb4fb100..9396db931 100644
--- a/clarity/evaluator/msbg/msbg_utils.py
+++ b/clarity/evaluator/msbg/msbg_utils.py
@@ -10,6 +10,7 @@
 import scipy
 import scipy.signal
 import soundfile
+from numpy import float64, ndarray
 from soundfile import SoundFile
 
 # measure rms parameters
@@ -17,7 +18,6 @@
 
 # read & write signal parameters
 MSBG_FS = 44100
-TEST_NBITS = 16
 
 # fmt: off
 HZ = np.array(
@@ -96,7 +96,7 @@
 ) * (7.5 / 9)
 
 
-def read_gtf_file(gtf_file: str) -> dict:
+def read_gtf_file(gtf_file: str) -> dict[str, str | ndarray | int | float]:
     """Read a gammatone filterbank file.
 
     List data is converted into numpy arrays.
@@ -115,11 +115,11 @@ def read_gtf_file(gtf_file: str) -> dict:
 
 def firwin2(
     n_taps: int,
-    frequencies: np.ndarray,
-    filter_gains: np.ndarray,
-    window: None | str | tuple = None,
-    antisymmetric: bool | None = None,
-):  # pylint: disable=W0613
+    frequencies: list[float] | ndarray,
+    filter_gains: list[int | float] | ndarray,
+    window: tuple[str, int] | str | None = None,
+    antisymmetric: bool | None = None,  # pylint: disable=W0613
+) -> ndarray:  # pylint: disable=W0613
     """FIR filter design using the window method.
 
     Partial implementation of scipy firwin2 but using our own MATLAB-derived fir2.
@@ -130,7 +130,7 @@ def firwin2(
             being Nyquist.
         filter_gains (ndarray): The filter gains at the frequency sampling points.
         window (string or (string, float), optional): See scipy.firwin2. Default is None
-        antisymmetric (bool, optional): Unused but present to main compatability
+        antisymmetric (bool, optional): Unused but present to maintain compatibility
             with scipy firwin2.
 
     Returns:
@@ -159,10 +159,10 @@ def firwin2(
 
 def fir2(
     filter_length: int,
-    frequencies: np.ndarray,
-    filter_gains: np.ndarray,
-    n_interpolate: int | None = None,
-) -> tuple[np.ndarray, int]:
+    frequencies: list[float] | ndarray,
+    filter_gains: list[int | float] | ndarray,
+    n_interpolate: ndarray | None = None,
+) -> tuple[ndarray, int]:
     """FIR arbitrary shape filter design using the frequency sampling method.
 
     Translation of MATLAB fir2.
@@ -232,8 +232,11 @@ def fir2(
 
 
 def gen_tone(
-    freq: float, duration: float, sample_frequency: float = 44100.0, level: float = 0.0
-) -> np.ndarray:
+    freq: int,
+    duration: float,
+    sample_frequency: float = 44100.0,
+    level: int | float | float64 = 0.0,
+) -> ndarray:
     """Generate a pure tone.
 
     Args:
@@ -260,11 +263,11 @@ def gen_tone(
 
 
 def gen_eh2008_speech_noise(
-    duration: float,
+    duration: int | float,
     sample_frequency: float = 44100.0,
-    level: float | None = None,
-    supplied_b: np.ndarray | None = None,
-) -> np.ndarray:
+    level: int | float | float64 | None = None,
+    supplied_b: None = None,
+) -> ndarray:
     """Generate speech shaped noise.
 
     Start with white noise and re-shape to ideal SII, ie flat to 500 Hz, and sloping
@@ -338,11 +341,11 @@ def gen_eh2008_speech_noise(
 
 
 def generate_key_percent(
-    signal: np.ndarray,
-    threshold_db: float,
+    signal: ndarray,
+    threshold_db: float64,
     window_length: int,
     percent_to_track: float | None = None,
-) -> tuple[np.ndarray, float]:
+) -> tuple[ndarray, float64]:
     """Generate key percent.
     Locates frames above some energy threshold or tracks a certain percentage
     of frames. To track a certain percentage of frames in order to get measure
@@ -446,11 +449,11 @@ def generate_key_percent(
 
 
 def measure_rms(
-    signal: np.ndarray,
-    sample_frequency: float,
-    db_rel_rms: float,
+    signal: ndarray,
+    sample_frequency: int,
+    db_rel_rms: int | float,
     percent_to_track: float | None = None,
-) -> tuple:
+) -> tuple[float64, ndarray, float64, float]:
     """Measure Root Mean Square.
 
     A sophisticated method of measuring RMS in a file. It splits the signal up into
@@ -496,12 +499,13 @@ def measure_rms(
     return rms, idx, rel_db_thresh, active
 
 
-def pad(signal, length):
+def pad(signal: ndarray, length: int) -> ndarray:
     """Zero pad signal to required length.
 
     Assumes required length is not less than input length.
     """
-    assert length >= signal.shape[0]
+    if length < signal.shape[0]:
+        raise ValueError("Required length is less than input length")
     return np.pad(
         signal, [(0, length - signal.shape[0])] + [(0, 0)] * (len(signal.shape) - 1)
     )
@@ -513,7 +517,7 @@ def read_signal(
     nsamples: int = -1,
     nchannels: int = 0,
     offset_is_samples: bool = False,
-) -> np.ndarray:
+) -> ndarray:
     """Read a wavefile and return as numpy array of floats.
 
     Args:
@@ -552,17 +556,23 @@ def read_signal(
 
 def write_signal(
     filename: str | Path,
-    signal: np.ndarray,
-    sample_frequency: float,
+    signal: ndarray,
+    sample_frequency: int,
     floating_point: bool = True,
+    strict: bool = False,
 ) -> None:
     """Write a signal as fixed or floating point wav file.
 
+    NB: setting 'strict' to True will raise error on overflow. This would be
+    a more natural default but it would break existing code that did not
+    check for overflow.
+
     Args:
         filename (str|Path): name of file in to write to.
         signal (ndarray): signal to write.
         sample_frequency (float): sampling frequency.
         floating_point (bool): write as floating point else an ints (default: True).
+        strict (bool): raise error if signal out of range for int16 (default: False).
     """
 
     if sample_frequency != MSBG_FS:
@@ -573,14 +583,13 @@ def write_signal(
         # raise ValueError("Sampling rate mismatch")
 
     if floating_point is False:
-        if TEST_NBITS == 16:
-            subtype = "PCM_16"
-            # If signal is float and we want int16
-            signal *= 32768
-            signal = signal.astype(np.dtype("int16"))
-            assert np.max(signal) <= 32767 and np.min(signal) >= -32768
-        elif TEST_NBITS == 24:
-            subtype = "PCM_24"
+        subtype = "PCM_16"
+        # Signal is float and we want to convert to int16
+        # *NB* Not  *= in next line as we need to make a copy
+        signal = signal * 32768
+        if strict and (np.max(signal) > 32767 or np.min(signal) < -32768):
+            raise ValueError("Signal out of range -1.0 to 1.0")
+        signal = signal.astype(np.dtype("int16"))
     else:
         subtype = "FLOAT"
 
diff --git a/clarity/evaluator/msbg/smearing.py b/clarity/evaluator/msbg/smearing.py
index 6faa54d1b..b5bddd45f 100644
--- a/clarity/evaluator/msbg/smearing.py
+++ b/clarity/evaluator/msbg/smearing.py
@@ -4,6 +4,7 @@
 import math
 
 import numpy as np
+from numpy import ndarray
 
 FFT_SIZE = 512
 FRAME_SIZE = 256
@@ -15,7 +16,7 @@ def audfilt(
     ru: int | float,
     sampfreq: int | float,
     asize: int = 256,
-) -> np.ndarray:
+) -> ndarray:
     """Calculate an auditory filter array.
 
     Args:
@@ -52,7 +53,7 @@ def audfilt(
     return aud_filter
 
 
-def make_smear_mat3(rl: float, ru: float, fs: int | float) -> np.darray:
+def make_smear_mat3(rl: float, ru: float, fs: int | float) -> ndarray:
     """Make the smearing filter matrix.
 
     Args:
@@ -90,7 +91,7 @@ def make_smear_mat3(rl: float, ru: float, fs: int | float) -> np.darray:
     return f_smear
 
 
-def smear3(f_smear: np.ndarray, inbuffer: np.ndarray) -> np.ndarray:
+def smear3(f_smear: ndarray, inbuffer: ndarray) -> ndarray:
     """Direct translation of smear3.m from MSBG hearing loss model.
 
     Args:
@@ -168,6 +169,6 @@ def __init__(self, rl: float, ru: float, fs: int | float) -> None:
         self.fs = fs
         self.f_smear = make_smear_mat3(rl, ru, fs)
 
-    def smear(self, input_signal: np.ndarray) -> np.ndarray:
+    def smear(self, input_signal: ndarray) -> ndarray:
         """Smear a given input signal."""
         return smear3(self.f_smear, input_signal)
diff --git a/clarity/predictor/torch_msbg.py b/clarity/predictor/torch_msbg.py
index aaa136c44..2e613d5d7 100644
--- a/clarity/predictor/torch_msbg.py
+++ b/clarity/predictor/torch_msbg.py
@@ -1,6 +1,8 @@
 """
 An FIR-based torch implementation of approximated MSBG hearing loss model
 """
+from __future__ import annotations
+
 import json
 from pathlib import Path
 
@@ -21,6 +23,7 @@
     ITU_HZ,
     MIDEAR,
 )
+from clarity.evaluator.msbg.smearing import make_smear_mat3
 
 EPS = 1e-8
 # old msbg matlab
@@ -32,7 +35,7 @@
 CALIB_DB_SPL = 65
 
 # what 0dB file signal would translate to in dB SPL:
-# constant for cochlear_simulate function
+# constant for cochlea_simulate function
 EQUIV_0_DB_FILE_SPL = CALIB_DB_SPL - REF_RMS_DB
 
 # clarity msbg
@@ -41,134 +44,17 @@
 EQUIV_0_DB_SPL = EQUIV_0_DB_SPL + AHR
 
 
-def generate_key_percent(signal, thr_db, win_len):
-    if win_len != np.floor(win_len):
-        win_len = np.int(np.floor(win_len))
-        print("\nGenerate_key_percent: \t Window length must be integer")
-
-    signal_len = len(signal)
-    expected = thr_db.copy()  # expected threshold
-    non_zero = 10.0 ** ((expected - 30) / 10)  # put floor into histogram distribution
-
-    n_frames = 0
-    total_frames = np.int(np.floor(signal_len / win_len))
-    every_db = np.zeros(total_frames)
-
-    for ix in range(total_frames):
-        start = ix * win_len
-        this_sum = np.sum(signal[start : start + win_len] ** 2)  # sum of squares
-        every_db[n_frames] = 10 * np.log10(non_zero + this_sum / win_len)
-        n_frames += 1
-
-    used_thr_db = expected.copy()
-
-    # histogram should produce a two-peaked curve: thresh should be set in valley
-    # between the two peaks, and set a bit above that, as it heads for main peak
-    frame_idx = np.where(every_db >= expected)[0]
-    valid_frames = len(frame_idx)
-    key = np.zeros(valid_frames * win_len, dtype=np.int)
-
-    # convert frame numbers into indices for signal
-    for ix in range(valid_frames):
-        key[ix * win_len : ix * win_len + win_len] = np.arange(
-            frame_idx[ix] * win_len, frame_idx[ix] * win_len + win_len, dtype=np.int
-        )
-    return key, used_thr_db
-
-
-def measure_rms(signal, sr, db_rel_rms):
-    """Compute RMS level of a signal.
-
-    Measures total power of all 10 msec frames that are above a user-specified threshold
-
-    Args:
-        signal: input signal
-        sr: sampling rate
-        db_rel_rms: threshold relative to first-stage rms (if it is made of a 2*1 array,
-            second value over rules. only single value supported currently)
-
-    Returns:
-        tuple: The percentage of frames that are required to be tracked for measuring
-        RMS (useful when DR compression changes histogram shape)
-    """
-    win_secs = 0.01
-    # first RMS is of all signal
-    first_stage_rms = np.sqrt(np.mean(signal**2))
-    # use this RMS to generate key threshold to more accurate RMS
-    key_thr_db = np.max([20 * np.log10(first_stage_rms) + db_rel_rms, -80])
-    key, used_thr_db = generate_key_percent(
-        signal, key_thr_db, np.int(np.round(win_secs * sr))
-    )
-    # active = 100.0 * len(key) / len(signal)
-    rms = np.sqrt(np.mean(signal[key] ** 2))
-    rel_db_thresh = used_thr_db - 20 * np.log10(rms)
-    return rms, key, rel_db_thresh
-
-
-def makesmearmat3(rl, ru, sr):
-    fft_size = 512
-    nyquist = np.int(fft_size // 2)
-    f_nor = audfilt(1, 1, nyquist, sr)
-    f_wid = audfilt(rl, ru, nyquist, sr)
-    f_next = np.hstack([f_nor, np.zeros([nyquist, nyquist // 2])])
-
-    for i in np.arange(nyquist // 2 + 1, nyquist + 1, dtype=np.int):
-        f_next[i - 1, nyquist : np.min([2 * i - 1, 3 * nyquist // 2])] = np.flip(
-            f_nor[
-                i - 1, np.max([1, 2 * i - 3 * nyquist // 2]) - 1 : (2 * i - nyquist - 1)
-            ]
-        )
-    f_smear = np.linalg.lstsq(f_next, f_wid)[
-        0
-    ]  # https://stackoverflow.com/questions/33559946/numpy-vs-mldivide-matlab-operator
-    f_smear = f_smear[:nyquist, :]
-
-    return f_smear
-
-
-def audfilt(rl, ru, size, sr):
-    """Calculate an auditory filter array
-
-    Args:
-        rl: broadening factor on the lower side
-        ru: broadening factor on the upper side
-        size:
-        sr:
-
-    Returns:
-        np.ndarray
-    """
-    aud_filter = np.zeros([size, size])
-    aud_filter[0, 0] = 1.0
-    aud_filter[0, 0] = aud_filter[0, 0] / ((rl + ru) / 2)
-
-    g = np.zeros(size)
-    for i in np.arange(2, size + 1, 1, dtype=np.int):
-        f_hz = (i - 1) * sr / (2 * size)
-        erb_hz = 24.7 * ((f_hz * 0.00437) + 1)
-        pl = 4 * f_hz / (erb_hz * rl)
-        pu = 4 * f_hz / (erb_hz * ru)
-        j = np.arange(1, i, dtype=np.int)
-        g[j - 1] = np.abs((i - j) / (i - 1))
-        aud_filter[i - 1, j - 1] = (1 + (pl * g[j - 1])) * np.exp(-pl * g[j - 1])
-        j = np.arange(i, size + 1, dtype=np.int)
-        g[j - 1] = np.abs((i - j) / (i - 1))
-        aud_filter[i - 1, j - 1] = (1 + (pu * g[j - 1])) * np.exp(-pu * g[j - 1])
-        aud_filter[i - 1, :] = aud_filter[i - 1, :] / (erb_hz * (rl + ru) / (2 * 24.7))
-    return aud_filter
-
-
 class MSBGHearingModel(nn.Module):
     def __init__(
         self,
-        audiogram,
-        audiometric,
-        sr=44100,
-        spl_cali=True,
-        src_position="ff",
-        kernel_size=1025,
-        device=None,
-    ):
+        audiogram: list[int],
+        audiometric: list[int],
+        sr: int = 44100,
+        spl_cali: bool = True,
+        src_position: str = "ff",
+        kernel_size: int = 1025,
+        device: str | None = None,
+    ) -> None:
         super().__init__()
         self.sr = sr
         self.spl_cali = spl_cali
@@ -178,7 +64,6 @@ def __init__(
             self.device = "cuda" if torch.cuda.is_available() else "cpu"
         else:
             self.device = device
-
         # settings for audiogram
 
         audiogram = np.array(audiogram)
@@ -214,24 +99,25 @@ def __init__(
         corrn_used = np.append(corrn[ixf_useful], last_corrn)
         corrn_forward = 10 ** (0.05 * corrn_used)
         corrn_backward = 10 ** (0.05 * -1 * corrn_used)
-        n_wdw = np.int(2 * np.floor((sr / 16e3) * 368 / 2))
-        cochlear_filter_forward = firwin2(
+        n_wdw = int(2 * np.floor((sr / 16e3) * 368 / 2))
+
+        cochlea_filter_forward = firwin2(
             n_wdw + 1, hz_used / nyquist, corrn_forward, window=("kaiser", 4)
         )
-        cochlear_filter_backward = firwin2(
+        cochlea_filter_backward = firwin2(
             n_wdw + 1, hz_used / nyquist, corrn_backward, window=("kaiser", 4)
         )
-        self.cochlear_padding = len(cochlear_filter_forward) // 2
-        self.cochlear_filter_forward = (
+        self.cochlea_padding = len(cochlea_filter_forward) // 2
+        self.cochlea_filter_forward = (
             torch.tensor(
-                cochlear_filter_forward, dtype=torch.float32, device=self.device
+                cochlea_filter_forward, dtype=torch.float32, device=self.device
             )
             .unsqueeze(0)
             .unsqueeze(1)
         )
-        self.cochlear_filter_backward = (
+        self.cochlea_filter_backward = (
             torch.tensor(
-                cochlear_filter_backward, dtype=torch.float32, device=self.device
+                cochlea_filter_backward, dtype=torch.float32, device=self.device
             )
             .unsqueeze(0)
             .unsqueeze(1)
@@ -275,13 +161,13 @@ def __init__(
             gt4_bank = json.load(fp)
 
         if severe_not_moderate > 0:
-            f_smear = makesmearmat3(4, 2, sr)
+            f_smear = make_smear_mat3(4, 2, sr)
         elif severe_not_moderate == 0:
-            f_smear = makesmearmat3(2.4, 1.6, sr)
+            f_smear = make_smear_mat3(2.4, 1.6, sr)
         elif severe_not_moderate == -1:
-            f_smear = makesmearmat3(1.6, 1.1, sr)
+            f_smear = make_smear_mat3(1.6, 1.1, sr)
         elif severe_not_moderate == -2:
-            f_smear = makesmearmat3(1.001, 1.001, sr)
+            f_smear = make_smear_mat3(1.001, 1.001, sr)
 
         self.smear_nfft = 512
         self.smear_win_len = 256
@@ -433,7 +319,18 @@ def __init__(
         self.db_relative_rms = -12
         self.win_len = int(self.sr * win_sec)
 
-    def measure_rms(self, wav):
+    def measure_rms(self, wav: torch.Tensor) -> torch.Tensor:
+        """Compute RMS level of a signal.
+
+        Measures total power of all 10 msec frames that are above a specified
+          threshold of db_relative_rms
+
+        Args:
+            wav: input signal
+
+        Returns:
+            RMS level in dB
+        """
         bs = wav.shape[0]
         average_rms = torch.sqrt(torch.mean(wav**2, dim=1) + EPS)
         threshold_db = 20 * torch.log10(average_rms + EPS) + self.db_relative_rms
@@ -461,7 +358,7 @@ def measure_rms(self, wav):
         )
         return key_rms.unsqueeze(1)
 
-    def calibrate_spl(self, x):
+    def calibrate_spl(self, x: torch.Tensor) -> torch.Tensor:
         if self.spl_cali:
             level_re_fs = 10 * torch.log10(
                 torch.mean(x**2, dim=1, keepdim=True) + EPS
@@ -472,10 +369,12 @@ def calibrate_spl(self, x):
             x = x * 10 ** (0.05 * change_db)
         return x
 
-    def src_to_cochlea_filt(self, x, cochlear_filter):
-        return F.conv1d(x, cochlear_filter, padding=self.cochlear_padding)
+    def src_to_cochlea_filt(
+        self, x: torch.Tensor, cochlea_filter: torch.Tensor
+    ) -> torch.Tensor:
+        return F.conv1d(x, cochlea_filter, padding=self.cochlea_padding)
 
-    def smear(self, x):
+    def smear(self, x: torch.Tensor) -> torch.Tensor:
         """Padding issue needs to be worked out"""
         length = x.shape[2]
         x = x.view(x.shape[0], x.shape[2])
@@ -516,7 +415,7 @@ def smear(self, x):
         )
         return smeared_wav.unsqueeze(1)
 
-    def recruitment(self, x):
+    def recruitment(self, x: torch.Tensor) -> torch.Tensor:
         n_samples = x.shape[-1]
         ixhp = 0
         outputs = []
@@ -562,7 +461,7 @@ def recruitment(self, x):
         y = y * self.recruitment_out_coef
         return y
 
-    def recruitment_fir(self, x):
+    def recruitment_fir(self, x: torch.Tensor) -> torch.Tensor:
         n_samples = x.shape[-1]
         x = x.repeat([1, self.n_chans, 1])
         real = F.conv1d(
@@ -595,27 +494,27 @@ def recruitment_fir(self, x):
         y = y * self.recruitment_out_coef
         return y
 
-    def forward(self, x):
+    def forward(self, x: torch.Tensor) -> torch.Tensor:
         x = self.calibrate_spl(x)
         x = x.unsqueeze(1)
-        x = self.src_to_cochlea_filt(x, self.cochlear_filter_forward)
+        x = self.src_to_cochlea_filt(x, self.cochlea_filter_forward)
         x = self.smear(x)
         # x = self.recruitment(x)
         x = self.recruitment_fir(x)
-        y = self.src_to_cochlea_filt(x, self.cochlear_filter_backward)
+        y = self.src_to_cochlea_filt(x, self.cochlea_filter_backward)
         return y.squeeze(1)
 
 
 class torchloudnorm(nn.Module):
     def __init__(
         self,
-        sr=44100,
-        norm_lufs=-36,
-        kernel_size=1025,
-        block_size=0.4,
-        overlap=0.75,
-        gamma_a=-70,
-    ):
+        sr: int = 44100,
+        norm_lufs: int = -36,
+        kernel_size: int = 1025,
+        block_size: float = 0.4,
+        overlap: float = 0.75,
+        gamma_a: int = -70,
+    ) -> None:
         super().__init__()
         self.sr = sr
         self.norm_lufs = norm_lufs
@@ -657,12 +556,12 @@ def __init__(
         )
         self.gamma_a = gamma_a
 
-    def apply_filter(self, x):
+    def apply_filter(self, x: torch.Tensor) -> torch.Tensor:
         x = F.conv1d(x, self.high_shelf, padding=self.padding)
         x = F.conv1d(x, self.high_pass, padding=self.padding)
         return x
 
-    def integrated_loudness(self, x):
+    def integrated_loudness(self, x: torch.Tensor) -> torch.Tensor:
         x = self.apply_filter(x)
         x_unfold = self.unfold(x.unsqueeze(2))
 
@@ -685,12 +584,12 @@ def integrated_loudness(self, x):
         lufs = -0.691 + 10 * torch.log10(z_ave_gated_a_r + EPS)  # loudness
         return lufs
 
-    def normalize_loudness(self, x, lufs):
+    def normalize_loudness(self, x: torch.Tensor, lufs: torch.Tensor) -> torch.Tensor:
         delta_loudness = self.norm_lufs - lufs
         gain = torch.pow(10, delta_loudness / 20)
         return gain * x
 
-    def forward(self, x):
+    def forward(self, x: torch.Tensor) -> torch.Tensor:
         loudness = self.integrated_loudness(x.unsqueeze(1))
         y = self.normalize_loudness(x, loudness)
         return y
diff --git a/clarity/utils/signal_processing.py b/clarity/utils/signal_processing.py
index d3eae4e57..8297f4b75 100644
--- a/clarity/utils/signal_processing.py
+++ b/clarity/utils/signal_processing.py
@@ -1,18 +1,20 @@
+"""Signal processing utilities."""
 from __future__ import annotations
 
 import numpy as np
 
 
-def normalize_signal(signal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
-    """Standardize the signal to have zero mean and unit variance.
+def compute_rms(signal: np.ndarray) -> float:
+    """Compute RMS of signal
 
     Args:
-        signal: The signal to be standardized.
+        signal: Signal to compute RMS of.
     Returns:
-        The standardized signal and the reference signal.
+        float: RMS of the signal.
     """
-    ref = signal.mean(0)
-    return (signal - ref.mean()) / ref.std(), ref
+    if len(signal) == 0:
+        return 0
+    return np.sqrt(np.mean(np.square(signal)))
 
 
 def denormalize_signals(sources: np.ndarray, ref: np.ndarray) -> np.ndarray:
@@ -25,3 +27,15 @@ def denormalize_signals(sources: np.ndarray, ref: np.ndarray) -> np.ndarray:
     Returns:
         np.ndarray: Signal rescaled back to its original."""
     return sources * ref.std() + ref.mean()
+
+
+def normalize_signal(signal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+    """Standardize the signal to have zero mean and unit variance.
+
+    Args:
+        signal: The signal to be standardized.
+    Returns:
+        The standardized signal and the reference signal.
+    """
+    ref = signal.mean(0)
+    return (signal - ref.mean()) / ref.std(), ref
diff --git a/docs/conf.py b/docs/conf.py
index 27a645b87..e70c36fd7 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,13 +1,13 @@
+"""Sphinx configuration"""
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 #
 import os
 import sys
+from importlib.metadata import version
 from typing import Dict
 
-import clarity
-
 # -*- coding: utf-8 -*-
 #
 # Configuration file for the Sphinx documentation builder.
@@ -38,14 +38,13 @@
 
 # -- Project information -----------------------------------------------------
 
-project = "pyclarity"
-copyright = "2022, pyclarity authors"  # pylint: disable=redefined-builtin
-author = "pyclarity authors"
+PROJECT = "pyclarity"
+COPYRIGHT = "2022, pyclarity authors"
+AUTHOR = "pyclarity authors"
 
-# The short X.Y version
-version = ""
-# The full version, including alpha/beta/rc tags
-release = clarity.__version__
+RELEASE = version("pyclarity")
+# for example take major/minor
+VERSION = ".".join(RELEASE.split(".")[:2])  # type: ignore
 
 
 # -- General configuration ---------------------------------------------------
@@ -85,14 +84,14 @@
 }
 
 # The master toctree document.
-master_doc = "index"
+MASTER_DOC = "index"
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
 #
 # This is also used if you do content translation via gettext catalogs.
 # Usually you set "language" from the command line for these cases.
-language = "en"
+LANGUAGE = "en"
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
@@ -100,7 +99,7 @@
 exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
 
 # The name of the Pygments (syntax highlighting) style to use.
-pygments_style = None
+PYGMENTS_STYLE = None
 
 
 # -- Options for HTML output -------------------------------------------------
@@ -111,7 +110,7 @@
 # html_theme = 'sphinx_rtd_theme'
 # html_theme = "haiku"
 # html_theme = "renku"
-html_theme = "pydata_sphinx_theme"
+HTML_THEME = "pydata_sphinx_theme"
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
@@ -123,13 +122,12 @@
 #         {
 #             "rel": "icon",
 #             "sizes": "156x20",
-#             "href": "https://github.com/Mesnage-Org/clarity/actions/workflows/\
-#                           ci-tests.yml/badge.svg",
+#             "href": "",
 #         },
 #         {
 #             "rel": "icon",
 #             "sizes": "115x20",
-#             "href": "https://codecov.io/gh/Mesnage-Org/clarity",
+#             "href": "",
 #         },
 #     ],
 # }
@@ -153,7 +151,7 @@
 # -- Options for HTMLHelp output ---------------------------------------------
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = "claritydoc"
+HTMLHELP_BASENAME = "claritydoc"
 
 
 # -- Options for LaTeX output ------------------------------------------------
@@ -177,7 +175,7 @@
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
-    (master_doc, "clarity.tex", "clarity Documentation", [author], "manual"),
+    (MASTER_DOC, "clarity.tex", "clarity Documentation", [AUTHOR], "manual"),
 ]
 
 
@@ -185,7 +183,7 @@
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
-man_pages = [(master_doc, "clarity", "Clarity Documentation", [author], 1)]
+man_pages = [(MASTER_DOC, "clarity", "Clarity Documentation", [AUTHOR], 1)]
 
 
 # -- Options for Texinfo output ----------------------------------------------
@@ -194,14 +192,14 @@
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-    (master_doc, "Clarity", "Clarity Documentation", author, "Clarity", "", ""),
+    (MASTER_DOC, "Clarity", "Clarity Documentation", AUTHOR, "Clarity", "", ""),
 ]
 
 
 # -- Options for Epub output -------------------------------------------------
 
 # Bibliographic Dublin Core info.
-epub_title = project
+EPUB_TITLE = PROJECT
 
 # The unique identifier of the text. This can be a ISBN number
 # or the project homepage.
diff --git a/pyproject.toml b/pyproject.toml
index 2862389b8..d9f0c4ad1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,11 +1,137 @@
 [build-system]
 requires = [
-  "setuptools >= 40.9.0",
+  "setuptools >= 45",
+  "setuptools_scm[toml]>=6.2",
   "wheel",
-  "versioneer"
   ]
 build-backend = "setuptools.build_meta"
 
+[project]
+name = "pyclarity"
+description = "Tools for the Clarity Challenge"
+readme = "README.md"
+license = {text = "MIT"}
+dynamic = ["version"]
+authors = [
+  {name = "The PyClarity Team", email = "claritychallengecontact@gmail.com"},
+]
+classifiers = [
+  "Intended Audience :: Developers",
+  "Intended Audience :: Education",
+  "Intended Audience :: Science/Research",
+  "License :: OSI Approved :: MIT License",
+  "Operating System :: OS Independent",
+  "Programming Language :: Python :: 3",
+  "Natural Language :: English",
+]
+keywords = [
+  "hearing",
+  "signal processing",
+  "clarity challenge"
+]
+requires-python = ">=3.8"
+dependencies = [
+  "audioread>=2.1.9",
+  "gdown",
+  "hydra-core==1.1.1",
+  "hydra-submitit-launcher>=1.1.6",
+  "importlib-metadata",
+  "librosa>=0.8.1",
+  "matplotlib",
+  "numpy>=1.21.6",
+  "omegaconf>=2.1.1",
+  "pandas>=1.3.5",
+  "pyloudnorm>=0.1.0",
+  "pystoi",
+  "resampy",
+  "scikit-learn>=1.0.2",
+  "scipy>=1.7.3",
+  "SoundFile>=0.10.3.post1",
+  "torch<2.0",
+  "torchaudio",
+  "tqdm>=4.62.3",
+  "typing_extensions",
+]
+
+[project.optional-dependencies]
+tests = [
+  "coverage",
+  "isort",
+  "flake8",
+  "flake8-print",
+  "Flake8-pyproject",
+  "mypy",
+  "pre-commit",
+  "py",
+  "py-cpuinfo",
+  "pytest",
+  "pytest-cov",
+  "pytest-mock",
+  "pytest-mpl",
+  "pytest-regtest",
+  "pytest-xdist",
+  "yamllint",
+]
+docs =[
+  "sphinx",
+  "myst_parser",
+  "pydata_sphinx_theme",
+  "sphinx_markdown_tables",
+  "sphinx_rtd_theme",
+  "sphinxcontrib-mermaid",
+  "sphinxcontrib-napoleon",
+]
+dev = [
+  "black",
+  "pre-commit",
+  "pycodestyle",
+  "pylint",
+  "pylint-pytest",
+  "yamllint",
+]
+pypi =[
+  "build",
+  "wheel",
+  "setuptools_scm[toml]"
+]
+
+[project.urls]
+Source = "https://github.com/claritychallenge/clarity"
+Bug_Tracker = "https://github.com/claritychallenge/clarity/issues"
+Documentation = "https://claritychallenge.github.io/clarity_CC_doc"
+
+[tool.setuptools]
+# packages = ["clarity", "recipes"]
+# packages = "find"
+# py-modules = ["clarity", "recipes"]
+include-package-data = true
+#package-dir = ["clarity", "recipes"]
+
+[tool.setuptools.packages.find]
+include = ["clarity*", "recipes*"]
+exclude = ["tests*"]
+namespaces = false
+
+[tool.setuptools.package-data]
+clarity = [
+  "*.json",
+  "*.mat",
+  "*.yaml"
+]
+recipes = [
+  "*.csv",
+  "*.json",
+  "*.mat",
+  "*.yaml"
+]
+
+
+[tool.setuptools_scm]
+write_to = "clarity/_version.py"
+version_scheme = "post-release"
+local_scheme = "no-local-version"
+git_describe_command = "git describe --tags"
+
 [tool.pytest.ini_options]
 minversion = "7.0"
 addopts = "--cov clarity"
@@ -16,9 +142,18 @@ filterwarnings = [
     "ignore::UserWarning"
 ]
 
+[tool.coverage.run]
+source = ["clarity"]
+omit = [
+  "*conftest.py",
+  "*tests*",
+  "**/__init__*",
+  "clarity/_version.py",
+]
+
 [tool.black]
 line-length = 88
-target-version = ['py37']
+target-version = ['py38']
 exclude = '''
 
 (
@@ -30,21 +165,58 @@ exclude = '''
 )
 '''
 
+[flake8]
+ignore =[
+  "E203",
+  "E501",
+  "W503",
+]
+# docstring-convention = ""
+max-line-length = 88
+max-complexity = 18
+select = [
+  "B",
+  "C",
+  "E",
+  "F",
+  "W",
+  "T4",
+  "B9"
+]
+
+[tool.ruff]
+exclude = [
+    ".bzr",
+    ".direnv",
+    ".eggs",
+    ".git",
+    ".hg",
+    ".mypy_cache",
+    ".nox",
+    ".pants.d",
+    ".pytype",
+    ".ruff_cache",
+    ".svn",
+    ".tox",
+    ".venv",
+    "__pypackages__",
+    "_build",
+    "buck-out",
+    "build",
+    "dist",
+    "node_modules",
+    "venv",
+]
+# per-file-ignores = []
+line-length = 88
+target-version = "py38"
+select = ["B", "E", "F", "I001", "W"]
+# Allow autofix for all enabled rules (when `--fix`) is provided.
+fixable = ["A", "B", "C", "D", "E", "F", "R", "S", "W", "U"]
+
 [tool.mypy]
 ignore_missing_imports = true
 exclude = [
     "docs/*",
     "build/*"
-    ]
-
-[tool.coverage.run]
-omit = [
-    "clarity/_version.py"
 ]
-
-
-[tool.ruff]
-line-length = 88
-# pyflakes, pycodestyle, isort
-select = ["F", "E", "W", "B", "I001"]
-ignore = ["B905"]
\ No newline at end of file
diff --git a/recipes/cad1/task1/baseline/config.yaml b/recipes/cad1/task1/baseline/config.yaml
index 58f43c4ab..88de32bf8 100644
--- a/recipes/cad1/task1/baseline/config.yaml
+++ b/recipes/cad1/task1/baseline/config.yaml
@@ -8,9 +8,12 @@ path:
   listeners_valid_file: ${path.metadata_dir}/listeners.valid.json
   exp_folder: ./exp # folder to store enhanced signals and final results
 
+
+sample_rate: 44100
+
 nalr:
   nfir: 220
-  fs: 44100
+  fs: ${sample_rate}
 
 apply_compressor: False
 compressor:
@@ -23,6 +26,8 @@ compressor:
 soft_clip: True
 
 separator:
+  model: demucs   # demucs or openunmix
+  sources: [drums, bass, other, vocals]
   device: ~
 
 evaluate:
diff --git a/recipes/cad1/task1/baseline/enhance.py b/recipes/cad1/task1/baseline/enhance.py
index 18154a7d4..65711aa14 100644
--- a/recipes/cad1/task1/baseline/enhance.py
+++ b/recipes/cad1/task1/baseline/enhance.py
@@ -1,6 +1,8 @@
 """ Run the dummy enhancement. """
 from __future__ import annotations
 
+# pylint: disable=too-many-locals
+# pylint: disable=import-error
 import json
 import logging
 from pathlib import Path
@@ -9,20 +11,17 @@
 import numpy as np
 import pandas as pd
 import torch
+from numpy import ndarray
 from omegaconf import DictConfig
 from scipy.io import wavfile
 from torchaudio.pipelines import HDEMUCS_HIGH_MUSDB
-from torchaudio.transforms import Fade
+from torchaudio.transforms import Fade, Resample
 
 from clarity.enhancer.compressor import Compressor
 from clarity.enhancer.nalr import NALR
 from clarity.utils.signal_processing import denormalize_signals, normalize_signal
 from recipes.cad1.task1.baseline.evaluate import make_song_listener_list
 
-# pylint: disable=too-many-locals
-# pylint: disable=import-error
-
-
 logger = logging.getLogger(__name__)
 
 
@@ -72,7 +71,7 @@ def separate_sources(
     overlap_frames = overlap * sample_rate
     fade = Fade(fade_in_len=0, fade_out_len=int(overlap_frames), fade_shape="linear")
 
-    final = torch.zeros(batch, len(model.sources), channels, length, device=device)
+    final = torch.zeros(batch, 4, channels, length, device=device)
 
     while start < length - overlap_frames:
         chunk = mix[:, :, start:end]
@@ -142,6 +141,7 @@ def map_to_dict(sources: np.ndarray, sources_list: list[str]) -> dict:
 
 # pylint: disable=unused-argument
 def decompose_signal(
+    config: DictConfig,
     model: torch.nn.Module,
     signal: np.ndarray,
     sample_rate: int,
@@ -158,6 +158,7 @@ def decompose_signal(
     HDEMUCS model trained on the MUSDB18 dataset.
 
     Args:
+        config (DictConfig): Configuration object.
         model (torch.nn.Module): Torch model.
         signal (np.ndarray): Signal to be decomposed.
         sample_rate (int): Sample frequency.
@@ -169,12 +170,26 @@ def decompose_signal(
          Dictionary: Indexed by sources with the associated model as values.
     """
 
-    signal, ref = normalize_signal(signal)
-    sources = separate_sources(model, signal, sample_rate, device=device)
+    if config.separator.model == "demucs":
+        signal, ref = normalize_signal(signal)
+
+    model_sample_rate = (
+        model.sample_rate if config.separator.model == "openunmix" else 44100
+    )
+
+    if sample_rate != model_sample_rate:
+        resampler = Resample(sample_rate, model_sample_rate)
+        signal = resampler(signal)
+
+    sources = separate_sources(
+        model, torch.from_numpy(signal), sample_rate, device=device
+    )
     # only one element in the batch
     sources = sources[0]
-    sources = denormalize_signals(sources, ref)
-    signal_stems = map_to_dict(sources, model.sources)
+    if config.separator.model == "demucs":
+        sources = denormalize_signals(sources, ref)
+
+    signal_stems = map_to_dict(sources, config.separator.sources)
     return signal_stems
 
 
@@ -226,7 +241,6 @@ def process_stems_for_listener(
         audiogram_right (np.ndarray) : Right channel audiogram
         cfs (np.ndarray) : Center frequencies
         apply_compressor (bool) : Whether to apply the compressor
-
     Returns:
         processed_sources (dict) : Dictionary of processed stems
     """
@@ -247,6 +261,29 @@ def process_stems_for_listener(
     return processed_stems
 
 
+def clip_signal(signal: np.ndarray, soft_clip: bool = False) -> tuple[np.ndarray, int]:
+    """Clip and save the processed stems.
+
+    Args:
+        signal (np.ndarray): Signal to be clipped and saved.
+        soft_clip (bool): Whether to use soft clipping.
+
+    Returns:
+        signal (np.ndarray): Clipped signal.
+        n_clipped (int): Number of samples clipped.
+    """
+
+    if soft_clip:
+        signal = np.tanh(signal)
+    n_clipped = np.sum(np.abs(signal) > 1.0)
+    np.clip(signal, -1.0, 1.0, out=signal)
+    return signal, int(n_clipped)
+
+
+def to_16bit(signal: np.ndarray) -> np.ndarray:
+    return (32768.0 * signal).astype(np.int16)
+
+
 @hydra.main(config_path="", config_name="config")
 def enhance(config: DictConfig) -> None:
     """
@@ -282,7 +319,10 @@ def enhance(config: DictConfig) -> None:
     #     songs_train['Track Name'], listener_train_audiograms
     # )
 
-    separation_model = HDEMUCS_HIGH_MUSDB.get_model()
+    if config.separator.model == "demucs":
+        separation_model = HDEMUCS_HIGH_MUSDB.get_model()
+    else:
+        separation_model = torch.hub.load("sigsep/open-unmix-pytorch", "umxhq", niter=0)
     device, _ = get_device(config.separator.device)
     separation_model.to(device)
 
@@ -338,19 +378,20 @@ def enhance(config: DictConfig) -> None:
             # Decompose song only once
             prev_song_name = song_name
 
-            smaple_rate, mixture_signal = wavfile.read(
+            sample_rate, mixture_signal = wavfile.read(
                 Path(config.path.music_dir)
                 / split_directory
                 / song_name
                 / "mixture.wav"
             )
             mixture_signal = (mixture_signal / 32768.0).astype(np.float32).T
-            assert smaple_rate == config.nalr.fs
+            assert sample_rate == config.sample_rate
 
-            stems = decompose_signal(
+            stems: dict[str, ndarray] = decompose_signal(
+                config,
                 separation_model,
                 mixture_signal,
-                smaple_rate,
+                sample_rate,
                 device,
                 audiogram_left,
                 audiogram_right,
@@ -371,12 +412,12 @@ def enhance(config: DictConfig) -> None:
 
         # save processed stems
         n_samples = processed_stems[list(processed_stems.keys())[0]].shape[0]
-        out_left, out_right = np.zeros(n_samples), np.zeros(n_samples)
-        for stem_str, item in processed_stems.items():
+        output_left, output_right = np.zeros(n_samples), np.zeros(n_samples)
+        for stem_str, stem_signal in processed_stems.items():
             if stem_str.startswith("l"):
-                out_left += item
+                output_left += stem_signal
             else:
-                out_right += item
+                output_right += stem_signal
 
             filename = (
                 enhanced_folder
@@ -385,9 +426,14 @@ def enhance(config: DictConfig) -> None:
                 / f"{listener_info['name']}_{song_name}_{stem_str}.wav"
             )
             filename.parent.mkdir(parents=True, exist_ok=True)
-            wavfile.write(filename, config.nalr.fs, item)
 
-        enhanced = np.stack([out_left, out_right], axis=1)
+            # Clip and save stem signals
+            clipped_signal, n_clipped = clip_signal(stem_signal, config.soft_clip)
+            if n_clipped > 0:
+                logger.warning(f"Writing {filename}: {n_clipped} samples clipped")
+            wavfile.write(filename, config.sample_rate, to_16bit(clipped_signal))
+
+        enhanced = np.stack([output_left, output_right], axis=1)
         filename = (
             enhanced_folder
             / f"{listener_info['name']}"
@@ -395,15 +441,11 @@ def enhance(config: DictConfig) -> None:
             / f"{listener_info['name']}_{song_name}_remix.wav"
         )
 
-        # Clip and save
-        if config.soft_clip:
-            enhanced = np.tanh(enhanced)
-        n_clipped = np.sum(np.abs(enhanced) > 1.0)
+        # clip and save enhanced signal
+        clipped_signal, n_clipped = clip_signal(enhanced, config.soft_clip)
         if n_clipped > 0:
             logger.warning(f"Writing {filename}: {n_clipped} samples clipped")
-        np.clip(enhanced, -1.0, 1.0, out=enhanced)
-        signal_16 = (32768.0 * enhanced).astype(np.int16)
-        wavfile.write(filename, config.nalr.fs, signal_16)
+        wavfile.write(filename, config.sample_rate, to_16bit(clipped_signal))
 
 
 # pylint: disable = no-value-for-parameter
diff --git a/recipes/cad1/task1/baseline/evaluate.py b/recipes/cad1/task1/baseline/evaluate.py
index 266d09bc0..ae883ee6c 100644
--- a/recipes/cad1/task1/baseline/evaluate.py
+++ b/recipes/cad1/task1/baseline/evaluate.py
@@ -1,6 +1,8 @@
 """Evaluate the enhanced signals using the HAAQI metric."""
 from __future__ import annotations
 
+# pylint: disable=too-many-locals
+# pylint: disable=import-error
 import csv
 import hashlib
 import itertools
@@ -15,11 +17,7 @@
 from omegaconf import DictConfig
 from scipy.io import wavfile
 
-from clarity.evaluator.haaqi import compute_haaqi
-
-# pylint: disable=too-many-locals
-# pylint: disable=import-error
-
+from recipes.cad1.task1.baseline.haaqi_rms import compute_haaqi_rms
 
 logger = logging.getLogger(__name__)
 
@@ -158,50 +156,54 @@ def _evaluate_song_listener(
     ]:
         logger.info(f"...evaluating {instrument}")
 
+        # Read instrument reference signal
         sample_rate_reference_signal, reference_signal = wavfile.read(
             Path(config.path.music_dir) / split_dir / song / f"{instrument}.wav"
         )
-
-        # Read instrument reference signal
         reference_signal = (reference_signal / 32768.0).astype(np.float32)
-        left_reference_signal = reference_signal[:, 0]
-        right_reference_signal = reference_signal[:, 1]
 
-        # Read instrument enhanced
+        # Load enhanced instrument signals
+        # Load left channel
         sample_rate_left_enhanced_signal, left_enhanced_signal = wavfile.read(
             enhanced_folder
             / f"{listener}"
             / f"{song}"
             / f"{listener}_{song}_left_{instrument}.wav"
         )
+        left_enhanced_signal = (left_enhanced_signal / 32768.0).astype(np.float32)
+
+        # Load right channel
         sample_rate_right_enhanced_signal, right_enhanced_signal = wavfile.read(
             enhanced_folder
             / f"{listener}"
             / f"{song}"
             / f"{listener}_{song}_right_{instrument}.wav"
         )
+        right_enhanced_signal = (right_enhanced_signal / 32768.0).astype(np.float32)
 
         assert (
             sample_rate_reference_signal
             == sample_rate_left_enhanced_signal
             == sample_rate_right_enhanced_signal
-            == config.nalr.fs
+            == config.sample_rate
         )
 
         #  audiogram, audiogram_frequencies, fs_signal
-        per_instrument_score[f"left_{instrument}"] = compute_haaqi(
+        per_instrument_score[f"left_{instrument}"] = compute_haaqi_rms(
             left_enhanced_signal,
-            left_reference_signal,
+            reference_signal[:, 0],
             np.array(listener_audiograms["audiogram_levels_l"]),
             np.array(listener_audiograms["audiogram_cfs"]),
-            config.nalr.fs,
+            config.sample_rate,
+            silence_length=2.0,
         )
-        per_instrument_score[f"right_{instrument}"] = compute_haaqi(
+        per_instrument_score[f"right_{instrument}"] = compute_haaqi_rms(
             right_enhanced_signal,
-            right_reference_signal,
+            reference_signal[:, 1],
             np.array(listener_audiograms["audiogram_levels_r"]),
             np.array(listener_audiograms["audiogram_cfs"]),
-            config.nalr.fs,
+            config.sample_rate,
+            silence_length=2.0,
         )
 
     # Compute the combined score
@@ -212,7 +214,7 @@ def _evaluate_song_listener(
 
 @hydra.main(config_path="", config_name="config")
 def run_calculate_aq(config: DictConfig) -> None:
-    """Evaluate the enhanced signals using the HAAQI metric."""
+    """Evaluate the enhanced signals using the HAAQI-RMS metric."""
     # Load test songs
     with open(config.path.music_valid_file, encoding="utf-8") as fp:
         songs = json.load(fp)
diff --git a/recipes/cad1/task1/baseline/haaqi_rms.py b/recipes/cad1/task1/baseline/haaqi_rms.py
new file mode 100644
index 000000000..dc92b5549
--- /dev/null
+++ b/recipes/cad1/task1/baseline/haaqi_rms.py
@@ -0,0 +1,175 @@
+"""HAAQI-RMS metric"""
+from __future__ import annotations
+
+import numpy as np
+from scipy.signal import correlate, correlation_lags
+
+from clarity.evaluator.haaqi import compute_haaqi
+from clarity.utils.signal_processing import compute_rms
+
+
+def compute_haaqi_rms(
+    processed_signal: np.ndarray,
+    reference_signal: np.ndarray,
+    audiogram: np.ndarray,
+    audiogram_frequencies: np.ndarray,
+    sample_rate: int,
+    silence_length: float = 2.0,
+) -> float:
+    """Compute HAAQI-RMS metric
+
+    Metric is a combination of HAAQI and RMS. Signals are split into
+    non-silence and silence segments based on the reference signal.
+    HAAQI is computed on the non-silence parts and RMS is computed
+    on the silence parts.
+
+    The HAAQI part of the metric measure the music quality and the RMS
+    part of the metric punishes bad separations. I.e. if the RMS is high
+    it means that parts that should be silence contains residual or
+    artefact errors.
+
+    Args:
+        processed_signal (np.ndarray): Output signal with noise, distortion, HA gain,
+            and/or processing.
+        reference_signal (np.ndarray): Input reference speech signal with no
+            noise or distortion. If a hearing loss is specified, NAL-R
+            equalization is optional.
+        audiogram (np.ndarray): Vector of hearing loss at the audiogram_frequencies
+        audiogram_frequencies (np.ndarray): Audiogram frequencies
+        sample_rate (int): Sample rate in Hz.
+        silence_length (float): Minimum length of silence in seconds to use
+            for RMS calculation.
+            Segments of silence shorter than this are included in non_silence segments.
+            Defaults to 2.
+
+    Returns:
+        float: HAAQI-RMS metric
+    """
+
+    # align signals
+    processed_signal = align_signals(reference_signal, processed_signal)
+
+    # find silence segments
+    silence, non_silence = find_silence_segments(
+        reference_signal, sample_rate, silence_length
+    )
+
+    # join non-silence segments for processed and reference signals
+    new_processed_signal = []
+    new_reference_signal = []
+
+    for start, end in non_silence:
+        new_processed_signal.append(processed_signal[start:end])
+        new_reference_signal.append(reference_signal[start:end])
+
+    if len(new_processed_signal) > 1:
+        new_reference_signal = np.concatenate(new_reference_signal)
+        new_processed_signal = np.concatenate(new_processed_signal)
+    else:
+        new_reference_signal = new_reference_signal[0]
+        new_processed_signal = new_processed_signal[0]
+
+    # join silence segments for processed signal
+    silence_processed = []
+    for start, end in silence:
+        silence_processed.append(processed_signal[start:end])
+    silence_processed = np.concatenate(silence_processed)
+
+    # Compute haaqi on music segments
+    haaqi_score = compute_haaqi(
+        processed_signal=new_processed_signal,
+        reference_signal=new_reference_signal,
+        sample_rate=sample_rate,
+        audiogram=audiogram,
+        audiogram_frequencies=audiogram_frequencies,
+        equalisation=1,
+        level1=65 - 20 * np.log10(compute_rms(new_reference_signal)),
+        scale_reference=True,
+    )
+
+    # Compute rms on silence segments
+    rms_score = compute_rms(silence_processed)
+
+    return (
+        haaqi_score * len(new_reference_signal) - rms_score * len(silence_processed)
+    ) / (len(new_reference_signal) + len(silence_processed))
+
+
+def align_signals(
+    reference_signal: np.ndarray, processed_signal: np.ndarray
+) -> np.ndarray:
+    """Align processed signal to reference signals
+
+    Args:
+        processed_signal (np.ndarray): Output signal with noise, distortion, HA gain,
+            and/or processing.
+        reference_signal (np.ndarray): Input reference speech signal with no
+            noise or distortion. If a hearing loss is specified, NAL-R
+            equalization is optional.
+    Returns:
+        np.ndarray: Aligned processed signal
+    """
+
+    processed_n = len(processed_signal)
+
+    corr = correlate(processed_signal, reference_signal, mode="same")
+    lags = correlation_lags(processed_signal.size, reference_signal.size, mode="same")
+    delay = lags[np.argmax(corr)]
+
+    if delay > 0:
+        # Output delayed relative to the reference
+        processed_signal = np.concatenate(
+            (processed_signal[delay:processed_n], np.zeros(delay))
+        )
+    else:
+        # Output advanced relative to the reference
+        processed_signal = np.concatenate(
+            (np.zeros(-delay), processed_signal[: processed_n + delay])
+        )
+
+    return processed_signal
+
+
+def find_silence_segments(
+    signal: np.ndarray, sample_rate: int, min_silence_length: float = 1
+) -> tuple[list, list]:
+    """Find silence segments in signal
+
+    Args:
+        signal (np.ndarray): Input signal
+        sample_rate (int): Sample rate in Hz
+        min_silence_length (float): Minimum length of silence in
+            seconds classify silence or non_silence segment.
+            Defaults to 1.
+    Returns:
+        tuple[list, list]: Silence and non-silence segments
+    """
+    # Find the start and end of the noiseless reference sequence
+    reference_abs = np.abs(signal)
+    reference_max = np.max(reference_abs)
+    threshold = 0.001 * reference_max
+
+    # Find silence frames
+    silence = np.where(np.abs(signal) < threshold)[0]
+    silence = np.split(silence, np.where(np.diff(silence) != 1)[0] + 1)
+    silence = [
+        [group[0], group[-1]]
+        for group in silence
+        if len(group) > sample_rate * min_silence_length
+    ]
+    if len(silence) == 0:
+        silence = [[0, 0]]
+
+    # find silence segments
+    non_silence = []
+    for start, end in zip(silence[:-1], silence[1:]):
+        non_silence.append([start[-1] + 1, end[0] - 1])
+
+    # add first and last non-silence segments
+    if len(silence) > 0:
+        if silence[0][0] > 0:
+            non_silence.insert(0, [0, silence[0][0] - 1])
+        if silence[-1][-1] < len(signal) - 1:
+            non_silence.append([silence[-1][-1] + 1, len(signal) - 1])
+
+    return silence, non_silence
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 991e8d987..000000000
--- a/setup.cfg
+++ /dev/null
@@ -1,139 +0,0 @@
-[metadata]
-name = pyclarity
-description = Tools for the Clarity Challenge
-long_description = file: README.md,
-long_description_content_type = text/markdown
-author = The PyClarity team
-author_email = clarity-group@sheffield.ac.uk
-url = https://github.com/claritychallenge/clarity
-project_urls=
-  Bug Tracker = https://github.com/claritychallenge/clarity/issues
-  Documentation = https://claritychallenge.github.io/clarity_CC_doc
-  Source = https://github.com/claritychallenge/clarity
-license = MIT
-keywords = machine learning, challenges, speech-enhancement, hearing-aids
-classifiers=
-    Intended Audience :: Developers
-    Intended Audience :: Education
-#    Intended Audience :: Hearing Industry
-    Intended Audience :: Science/Research
-    License :: OSI Approved :: MIT License
-    Operating System :: OS Independent
-    Programming Language :: Python :: 3
-    Natural Language :: English
-
-
-[options]
-python_requires = >=3.7
-
-zip_safe = False
-
-include_package_data = True
-
-packages = find:
-
-install_requires =
-  audioread>=2.1.9
-  fastdtw
-  gdown
-  hydra-core==1.1.1
-  hydra-submitit-launcher>=1.1.6
-  importlib-metadata
-  librosa>=0.8.1
-  matplotlib
-  numpy>=1.21.6
-  omegaconf>=2.1.1
-  pandas>=1.3.5
-  pyloudnorm>=0.1.0
-  pytorch-lightning
-  pystoi
-  resampy
-  scikit-learn>=1.0.2
-  scipy>=1.7.3
-  SoundFile>=0.10.3.post1
-  speechbrain
-  torch<2.0
-  torchaudio
-  tqdm>=4.62.3
-  typing_extensions
-
-[options.extras_require]
-tests =
-  isort
-  flake8
-  mypy
-  pre-commit
-  py
-  py-cpuinfo
-  pytest
-  pytest-cov
-  pytest-mpl
-  pytest-regtest
-  yamllint
-
-docs =
-  sphinx
-  myst_parser
-  pydata_sphinx_theme
-  sphinx_markdown_tables
-  sphinx_rtd_theme
-  sphinxcontrib-mermaid
-  sphinxcontrib-napoleon
-
-dev =
-  black
-  coverage
-  flake8
-  flake8-print
-  isort
-  mypy
-  pre-commit
-  pycodestyle
-  pylint
-  pylint-pytest
-  pytest
-  pytest-cov
-  pytest-regtest
-  yamllint
-
-pypi =
-  build
-  versioneer
-  wheel
-
-[options.package_data]
-
-* =
-  *.csv
-  *.json
-  *.mat
-  *.yaml
-
-[flake8]
-ignore = E203, E501, W503
-max-line-length = 88
-max-complexity = 18
-select = B,C,E,F,W,T4,B9
-
-[options.packages.find]
-exclude = tests*
-
-[coverage:run]
-omit =
-  *conftest.py
-  *tests*
-  **/__init__*
-source =
-    clarity
-
-# FIXME : Is an entry_point required (https://setuptools.pypa.io/en/latest/userguide/entry_point.html)
-# [options.entry_points]
-# console_scripts =
-
-[versioneer]
-VCS = git
-style = pep440
-versionfile_source = clarity/_version.py
-versionfile_build = clarity/_version.py
-tag_prefix = v
-parentdir_prefix =
\ No newline at end of file
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 530a0572f..000000000
--- a/setup.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""Package setup"""
-import versioneer
-from setuptools import setup
-
-setup(
-    version=versioneer.get_version(),  # pylint: disable=no-member
-    cmdclass=versioneer.get_cmdclass(),  # pylint: disable=no-member
-)


Test patch:
diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml
index 8368b673c..b953b8879 100644
--- a/.github/workflows/run_tests.yml
+++ b/.github/workflows/run_tests.yml
@@ -16,7 +16,7 @@ jobs:
     strategy:
       matrix:
         os: ["ubuntu-latest"]
-        python-version: ['3.7', '3.8', '3.9', '3.10']
+        python-version: ['3.8', '3.9', '3.10']
     steps:
       - uses: actions/checkout@v3
       - name: Set up Python
@@ -31,10 +31,7 @@ jobs:
           sudo apt-get install -yq libsndfile1-dev ffmpeg
       - name: Install package, including extras
         run: |
-          pip install -e .
-          pip install -e .[tests]
-          pip install -e .[docs]
-          pip install -e .[dev]
+          pip install -e .[tests,docs,dev]
       - name: CPU information
         run: |
           python -m cpuinfo --json
diff --git a/tests/enhancer/dnn/__init__.py b/tests/enhancer/dnn/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/enhancer/dnn/test_mc_conv_tasnet.py b/tests/enhancer/dnn/test_mc_conv_tasnet.py
new file mode 100644
index 000000000..9b1a49dcb
--- /dev/null
+++ b/tests/enhancer/dnn/test_mc_conv_tasnet.py
@@ -0,0 +1,1 @@
+"""Tests for enhancer.dnn.test_mc_conv_tasnet module"""
diff --git a/tests/enhancer/dsp/test_filter.py b/tests/enhancer/dsp/test_filter.py
new file mode 100644
index 000000000..1cddcf7af
--- /dev/null
+++ b/tests/enhancer/dsp/test_filter.py
@@ -0,0 +1,45 @@
+"""Tests for enhancer.dsp.filter module"""
+
+import pytest
+import torch
+
+from clarity.enhancer.dsp.filter import AudiometricFIR
+
+SAMPLE_RATE = 44100  # Sample rate of the signal
+NFIR = 220  # Number of FIR filter taps to use in tests
+
+
+@pytest.fixture(autouse=True)
+def use_torch():
+    """Fixture to ensure torch is used with a consistent seed and settings"""
+    torch.manual_seed(0)
+    torch.set_num_threads(1)
+    torch.set_default_tensor_type(torch.FloatTensor)
+    torch.set_printoptions(precision=10)
+
+
+def test_audiometric_filter_init():
+    """test construction of audiomatric filter"""
+    fir_filter = AudiometricFIR(sr=SAMPLE_RATE, nfir=NFIR)
+    assert fir_filter.padding == NFIR // 2
+    assert fir_filter.window_size == NFIR + 1
+
+
+def test_audiometric_filter_forward():
+    """test that the filter can be applied"""
+    fir_filter = AudiometricFIR(sr=SAMPLE_RATE, nfir=NFIR, device="cpu")
+    audio = torch.randn(1, 1, 4410)
+    filtered_audio = fir_filter(audio)
+    assert filtered_audio.shape == audio.shape
+    assert filtered_audio.cpu().detach().numpy().sum() == pytest.approx(
+        -60.199371337890625, abs=1e-4  # <- had to relax this tolerance
+    )
+
+
+def test_audiometric_filter_forward_error():
+    """test that the filter throws error with invalid signal shapes"""
+    fir_filter = AudiometricFIR(sr=SAMPLE_RATE, nfir=NFIR)
+    with pytest.raises(RuntimeError):
+        fir_filter(torch.randn(2, 44100))
+    with pytest.raises(RuntimeError):
+        fir_filter(torch.randn(1, 2, 44100))
diff --git a/tests/enhancer/gha/__init__.py b/tests/enhancer/gha/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/enhancer/gha/test_audiogram.py b/tests/enhancer/gha/test_audiogram.py
new file mode 100644
index 000000000..3d7394ed1
--- /dev/null
+++ b/tests/enhancer/gha/test_audiogram.py
@@ -0,0 +1,76 @@
+"""Tests for enhancer.gha.audiogram module"""
+
+import numpy as np
+import pytest
+
+from clarity.enhancer.gha.audiogram import Audiogram
+
+
+def test_audiogram_init():
+    """test construction of audiogram"""
+    levels_l = np.array([45, 45, 35, 45, 60, 65])
+    levels_r = np.array([45, 45, 35, 45, 60, 65])
+    cfs = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels_l=levels_l, levels_r=levels_r, cfs=cfs)
+    assert np.all(audiogram.levels_l == levels_l)
+    assert np.all(audiogram.levels_r == levels_r)
+    assert np.all(audiogram.cfs == cfs)
+    assert isinstance(audiogram.levels_l, np.ndarray)
+
+
+def test_audiogram_init_error():
+    """test that cannot be constructed with invalid number of arguments"""
+    # Need to disable pylint so that we can test the invalid constructor calls
+    with pytest.raises(TypeError):
+        Audiogram()  # pylint: disable=no-value-for-parameter
+    with pytest.raises(TypeError):
+        Audiogram([1])  # pylint: disable=no-value-for-parameter
+    with pytest.raises(TypeError):
+        Audiogram(3, 4, 5, 6)  # pylint: disable=too-many-function-args
+
+
+@pytest.mark.parametrize(
+    "left, right, cfs, expected",
+    [
+        ([45], [45], [250], ["NOTHING", "NOTHING"]),
+        ([15], [16], [2000], ["NOTHING", "MILD"]),
+        ([35], [36], [2000], ["MILD", "MODERATE"]),
+        ([56], [57], [2000], ["MODERATE", "SEVERE"]),
+        ([90], [90], [8001], ["NOTHING", "NOTHING"]),
+        ([90, 90], [16, 16], [8000, 8001], ["SEVERE", "MILD"]),
+    ],
+)
+def test_severity(left, right, cfs, expected):
+    """test severity calculation"""
+    audiogram = Audiogram(
+        levels_l=np.array(left), levels_r=np.array(right), cfs=np.array(cfs)
+    )
+    assert audiogram.severity == expected
+
+
+def test_select_subset_of_cfs():
+    """test that a subset of cfs can be chosen"""
+    levels_l = np.array([1, 2, 3, 4, 5, 6])
+    levels_r = np.array([11, 12, 13, 14, 15, 16])
+    cfs = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels_l=levels_l, levels_r=levels_r, cfs=cfs)
+
+    # Select a subset of cfs
+    subset_audiogram = audiogram.select_subset_of_cfs([250, 1000, 6000])
+    assert np.all(subset_audiogram.levels_l == np.array([1, 3, 6]))
+    assert np.all(subset_audiogram.levels_r == np.array([11, 13, 16]))
+    assert np.all(subset_audiogram.cfs == np.array([250, 1000, 6000]))
+
+    # Include cfs that don't exist in the requested subset
+    subset_audiogram = audiogram.select_subset_of_cfs([251, 1000, 6001])
+    assert np.all(subset_audiogram.levels_l == np.array([3]))
+    assert np.all(subset_audiogram.levels_r == np.array([13]))
+    assert np.all(subset_audiogram.cfs == np.array([1000]))
+
+    # Include only a cf that doesn't exist in the requested subset
+    subset_audiogram = audiogram.select_subset_of_cfs([999])
+    assert np.all(subset_audiogram.levels_l == np.array([]))
+    assert np.all(subset_audiogram.levels_r == np.array([]))
+    assert np.all(subset_audiogram.cfs == np.array([]))
+    # Severity of an 'empty' audiogram should be NOTHING
+    assert subset_audiogram.severity == ["NOTHING", "NOTHING"]
diff --git a/tests/enhancer/gha/test_gainrule_camfit.py b/tests/enhancer/gha/test_gainrule_camfit.py
new file mode 100644
index 000000000..4289014e5
--- /dev/null
+++ b/tests/enhancer/gha/test_gainrule_camfit.py
@@ -0,0 +1,215 @@
+"""Tests for enhancer.gha.gainrule_camfit module"""
+import numpy as np
+import pytest
+
+from clarity.enhancer.gha.audiogram import Audiogram
+from clarity.enhancer.gha.gainrule_camfit import (
+    compute_proportion_overlap,
+    freq_interp_sh,
+    gainrule_camfit_compr,
+    gainrule_camfit_linear,
+    gains,
+    isothr,
+)
+
+
+@pytest.mark.parametrize(
+    "a1, a2, b1, b2, expected",
+    [
+        (0.0, 1.0, 0.0, 1.0, 1.0),
+        (0.0, 1.0, 2.0, 3.0, 0.0),
+        (0.0, 10.0, 5.0, 15.0, 0.5),
+        (0.0, 10.0, 5.0, 10.0, 1.0),
+        (10.0, 20.0, 5.0, 15.0, 0.5),
+        (10.0, 20.0, 19.0, 29.0, 0.1),
+        (2.0, 4.0, 0.0, 10.0, 0.2),
+    ],
+)
+def test_compute_proportion_overlap(a1, a2, b1, b2, expected):
+    """test that the proportion overlap is computed correctly"""
+    assert compute_proportion_overlap(a1, a2, b1, b2) == pytest.approx(expected)
+
+
+def test_isothr():
+    """test that the isothr is computed correctly"""
+    inputs_1 = [30, 40, 50, 60, 70, 80]
+    inputs_2 = [35, 45, 50, 60, 70, 80]  # <- same as inputs_1 as all rounded up to 50
+    inputs_3 = [35, 55, 50, 60, 70, 80]
+    outputs_1 = isothr(inputs_1)
+    outputs_2 = isothr(inputs_2)
+    outputs_3 = isothr(inputs_3)
+
+    assert len(inputs_1) == len(outputs_1)
+    assert np.all(outputs_1 == outputs_2)  # they should be the same
+    assert not np.all(outputs_1 == outputs_3)  # they should be the different
+
+    assert np.sum(outputs_1) == pytest.approx(237.52941176470588)
+    assert np.sum(outputs_2) == pytest.approx(237.52941176470588)
+    assert np.sum(outputs_3) == pytest.approx(235.02941176470588)
+
+
+@pytest.mark.parametrize(
+    "f_in, y_in, f_out, expected_y_out",
+    [
+        ([10.0, 20.0], [2.0, 10.0], [10.0, 20.0], [2.0, 10.0]),
+        ([10.0, 20.0], [2.0, 10.0], [5.0, 15.0, 30.0], [2.0, 6.67970001, 10.0]),
+        ([10.0, 20.0], [2.0, 10.0], [15.0], [6.67970001]),
+        ([10.0, 20.0], [2.0, 10.0], [50.0, 1], [10.0, 2.0]),
+        ([10.0], [2.0], [50.0, 1], [2.0, 2.0]),
+    ],
+)
+def test_freq_inter_sh(f_in, y_in, f_out, expected_y_out):
+    """test that the freq_inter_sh interpolates and extrapolates correctly"""
+    y_out = freq_interp_sh(f_in=np.array(f_in), y_in=np.array(y_in), f=np.array(f_out))
+    y_out = np.squeeze(y_out, axis=0)
+    assert y_out.shape == np.array(f_out).shape
+    assert np.allclose(y_out, np.array(expected_y_out))
+
+
+@pytest.mark.parametrize(
+    "f_in, y_in, f_out, error_type",
+    [
+        ([10.0, 20.0], [2.0], [10.0, 20.0], ValueError),
+        ([10.0, 20.0], [2.0, 10.0, 30.0], [5.0, 15.0, 30.0], ValueError),
+        ([], [], [15.0], IndexError),
+    ],
+)
+def test_freq_inter_sh_error(f_in, y_in, f_out, error_type):
+    """test that the freq_inter_sh fails with invalid inputs"""
+    with pytest.raises(error_type):
+        freq_interp_sh(f_in=np.array(f_in), y_in=np.array(y_in), f=np.array(f_out))
+
+
+def test_gains():
+    """test that the gains are computed correctly"""
+    compr_thr_inputs = [30, 40, 50]
+    compr_thr_gains = [35, 35, 50]
+    compressions_ratios = [1.0, 2.0, 3.0]
+    levels = [0.0, 10.0, 100.0]
+    expected_outputs = np.array(
+        [[35.0, 55.0, 83.33333333], [35.0, 50.0, 76.66666667], [35.0, 5.0, 16.66666667]]
+    )
+    n_gains = len(compr_thr_inputs)
+    uncorrected_gains = gains(
+        compr_thr_inputs=compr_thr_inputs,
+        compr_thr_gains=compr_thr_gains,
+        compression_ratios=compressions_ratios,
+        levels=levels,
+    )
+    assert uncorrected_gains.shape == (n_gains, n_gains)
+    assert np.allclose(uncorrected_gains, expected_outputs)
+
+
+def test_gainrule_camfit_linear():
+    """test that the linear gain rule runs correctly"""
+    audiogram = Audiogram(
+        levels_l=np.array([30, 40, 50, 60, 70, 80]),
+        levels_r=np.array([30, 40, 50, 60, 70, 80]),
+        cfs=np.array([250, 500, 1000, 2000, 4000, 8000]),
+    )
+
+    sFitmodel = {
+        "frequencies": [250, 500, 1000, 2000, 4000, 8000],
+        "levels": [30, 40, 50, 60, 70, 80],
+    }
+    params = gainrule_camfit_linear(
+        audiogram=audiogram,
+        sFitmodel=sFitmodel,
+        noisegatelevels=45,
+        noisegateslope=1,
+        max_output_level=100,
+    )
+
+    assert params.keys() == {
+        "sGt",
+        "noisegate_level",
+        "noisegate_slope",
+        "insertion_gains",
+    }
+
+    assert params["sGt"].shape == (6, 6, 2)
+    assert params["noisegate_level"].shape == (6, 2)
+    assert params["noisegate_slope"].shape == (6, 2)
+    assert params["insertion_gains"].shape == (6, 2)
+
+    assert params["sGt"].sum() == pytest.approx(1589.2)
+    assert params["noisegate_level"].sum() == pytest.approx(540.0)
+    assert params["noisegate_slope"].sum() == pytest.approx(12.0)
+    assert params["insertion_gains"].sum() == pytest.approx(284.8)
+
+
+def test_gainrule_camfit_compr():
+    """test that the compr gain rule runs correctly"""
+    audiogram = Audiogram(
+        levels_l=np.array([30, 40, 50, 60, 70, 80]),
+        levels_r=np.array([30, 40, 50, 60, 70, 80]),
+        cfs=np.array([250, 500, 1000, 2000, 4000, 8000]),
+    )
+
+    sFitmodel = {
+        "frequencies": [250, 500, 1000, 2000, 4000, 8000],
+        "levels": [30, 40, 50, 60, 70, 80],
+        "edge_frequencies": [250, 8000],
+    }
+    params = gainrule_camfit_compr(
+        audiogram=audiogram,
+        sFitmodel=sFitmodel,
+        noisegatelevels=45,
+        noisegateslope=1,
+        level=0,
+        max_output_level=100,
+    )
+
+    assert params.keys() == {
+        "sGt",
+        "noisegatelevel",  # <-- note the keys are spelt different than in linear rule
+        "noisegateslope",
+    }
+
+    assert params["sGt"].shape == (6, 6, 2)
+    assert params["noisegatelevel"].shape == (6, 2)
+    assert params["noisegateslope"].shape == (6, 2)
+
+    assert params["sGt"].sum() == pytest.approx(2140.1762291737905)
+    assert params["noisegatelevel"].sum() == pytest.approx(540.0)
+    assert params["noisegateslope"].sum() == pytest.approx(12.0)
+
+
+@pytest.mark.parametrize(
+    "ng_levels, ng_slope, level, max_output_level, out_sgt, out_ng_level, out_ng_slope",
+    [
+        (45, 1, 0, 100, 2140.1762291737905, 540.0, 12.0),
+        (55, 1, 0, 100, 2140.1762291737905, 660.0, 12.0),
+        (45, 2, 0, 100, 2140.1762291737905, 540.0, 24.0),
+        # In next example level is changed to 30, the level must be in sFitmodel levels
+        (45, 1, 30, 100, 2140.1762291737905, 540.0, 12.0),
+        (45, 1, 0, 120, 2383.502544963264, 540.0, 12.0),
+    ],
+)
+def test_gainrule_camfit_compr_varying_params(
+    ng_levels, ng_slope, level, max_output_level, out_sgt, out_ng_level, out_ng_slope
+):
+    """test that the compr gain rule runs correctly for different parameters"""
+    audiogram = Audiogram(
+        levels_l=np.array([30, 40, 50, 60, 70, 80]),
+        levels_r=np.array([30, 40, 50, 60, 70, 80]),
+        cfs=np.array([250, 500, 1000, 2000, 4000, 8000]),
+    )
+
+    sFitmodel = {
+        "frequencies": [250, 500, 1000, 2000, 4000, 8000],
+        "levels": [30, 40, 50, 60, 70, 80],
+        "edge_frequencies": [250, 8000],
+    }
+    params = gainrule_camfit_compr(
+        audiogram=audiogram,
+        sFitmodel=sFitmodel,
+        noisegatelevels=ng_levels,
+        noisegateslope=ng_slope,
+        level=level,
+        max_output_level=max_output_level,
+    )
+
+    assert params["sGt"].sum() == pytest.approx(out_sgt)
+    assert params["noisegatelevel"].sum() == pytest.approx(out_ng_level)
+    assert params["noisegateslope"].sum() == pytest.approx(out_ng_slope)
diff --git a/tests/enhancer/gha/test_gha_interface.py b/tests/enhancer/gha/test_gha_interface.py
new file mode 100644
index 000000000..4c7a8c596
--- /dev/null
+++ b/tests/enhancer/gha/test_gha_interface.py
@@ -0,0 +1,177 @@
+"""Tests for enhancer.gha.gha_interface module"""
+import hashlib
+
+import numpy as np
+import pytest
+
+from clarity.enhancer.gha.audiogram import Audiogram
+from clarity.enhancer.gha.gha_interface import GHAHearingAid
+
+
+def setup_ha_and_data(root_path):
+    """Setup the hearing aid and make some dummy data file for testing
+
+    Args:
+        root_path (pathlib.Path): Path to temporary directory
+
+    Returns:
+        tuple: (gha_hearing_aid, stereo_signal, tmp_filenames)
+            gha_hearing_aid (GHAHearingAid): Hearing aid object
+            stereo_signal (np.ndarray): Dummy stereo signal stored in the files
+            tmp_filenames (list): List of temporary filenames
+    """
+    gha_hearing_aid = GHAHearingAid()
+
+    tmp_filenames = [str(root_path / f"xxx.CH{chan}.wav") for chan in [1, 2, 3, 0]]
+
+    stereo_signal = np.array(
+        [[-0.1, 0.1, -0.1, 0.1, 0.0, -1.0], [-0.2, 0.2, -0.2, 0.2, 0.0, 1.1]]
+    ).T  # <- signals are store with channels as columns
+
+    for filename in tmp_filenames:
+        gha_hearing_aid.write_signal(
+            filename,
+            stereo_signal,  # <- signals are store with channels as columns
+            floating_point=True,
+        )
+    return gha_hearing_aid, stereo_signal, tmp_filenames
+
+
+def test_gha_hearing_aid_init():
+    """test the the gha hearing aid is initialized correctly"""
+    gha_hearing_aid = GHAHearingAid()
+    assert len(gha_hearing_aid.audf) == 8
+    assert len(gha_hearing_aid.noisegatelevels) == 9
+
+
+def test_create_configured_cfgfile():
+    """test that the cfgfile can be correctly constructed"""
+    gha_hearing_aid = GHAHearingAid()
+    input_file = "input.wav"
+    output_file = "output.wav"
+    formatted_sGt = np.array([1, 2, 3])  # Some dummy data which will appear in output
+    cfg_template_file = (
+        "clarity/enhancer/gha/cfg_files/prerelease_combination3_smooth_template.cfg"
+    )
+    output = gha_hearing_aid.create_configured_cfgfile(
+        input_file, output_file, formatted_sGt, cfg_template_file
+    )
+
+    # Check that the output is as expected
+    assert (
+        hashlib.md5(output.encode("utf-8")).hexdigest()
+        == "b49533003cd6449fa8c4c5edacaef067"
+    )
+
+
+def test_create_configured_cfgfile_error():
+    """Check gives error if sampling rate is not 44.1kHz"""
+    gha_hearing_aid = GHAHearingAid()
+    gha_hearing_aid.fs = 16000  # <-- any value other than 44100
+    with pytest.raises(ValueError):
+        arbitrary_data = np.array([1, 2, 3])
+        gha_hearing_aid.create_configured_cfgfile(
+            "input.wav", "output.wav", arbitrary_data, "template.cfg"
+        )
+
+
+def test_process_files(mocker, tmp_path):
+    """test that the gha process can run"""
+
+    gha_hearing_aid, _stereo_signal, infile_names = setup_ha_and_data(tmp_path)
+
+    levels_l = np.array([45, 45, 35, 45, 60, 65])
+    levels_r = np.array([45, 45, 35, 45, 60, 65])
+    cfs = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels_l=levels_l, levels_r=levels_r, cfs=cfs)
+
+    # Mock the subprocess.run function as OpenMHA is not installed
+    m = mocker.patch("clarity.enhancer.gha.gha_interface.subprocess.run")
+
+    # write a dummy output file so that process_files still runs
+    # despite that the fact that the file generation is mocked out
+    outfile_name = tmp_path / "output.wav"
+
+    gha_hearing_aid.write_signal(
+        outfile_name,
+        np.array([[-0.1, 0.1], [-0.2, 0.2]]),
+        floating_point=True,
+    )
+
+    gha_hearing_aid.process_files(
+        infile_names=infile_names,
+        outfile_name=outfile_name,
+        audiogram=audiogram,
+        listener=None,
+    )
+    # Check that the subprocess.run function was called
+    m.assert_called_once()
+
+
+# TODO: Testing functions that are marked for deduplication
+# Remove this test when the functions are removed
+@pytest.mark.parametrize(
+    "signal, floating_point, strict",
+    [
+        (np.array([1.1, -2.0, 0.0, 44.0, -54.0]), True, True),  # float32
+        (np.array([0.1, -0.2, 0.1, -0.5, 0.99]), False, True),  # int16
+        (np.array([0.1, -0.2, 0.1, -1.00, 0.99]), False, True),  # int16
+        (np.array([0.1, -0.2, 0.1, -1.00, 1.99]), False, False),  # int16
+        (np.array([0.1, -0.2, 0.1, -1.00, 0.99]), False, True),  # int16
+    ],
+)
+def test_write_read_loop(tmp_path, signal, floating_point, strict):
+    """Test write_signal and read_signal"""
+    tmp_filename = tmp_path / "test.wav"
+
+    gha_hearing_aid = GHAHearingAid()
+
+    gha_hearing_aid.write_signal(
+        tmp_filename, signal, floating_point=floating_point, strict=strict
+    )
+    result = gha_hearing_aid.read_signal(tmp_filename)
+
+    # Some precision is lost as convert to int16 and back again
+    assert result.shape == signal.shape
+    # The test where strict is False has overflow which is not caught and hence
+    # reading back the signal has changed
+    if strict:
+        assert result == pytest.approx(signal, abs=1.0 / 16384)
+    else:
+        # Deliberate fail: shows why strict is True is needed
+        assert result != pytest.approx(signal, abs=1.0 / 16384)
+
+
+def test_create_ha_inputs(tmp_path):
+    """test that the hearing aid inputs can be created correctly"""
+
+    gha_hearing_aid, stereo_signal, tmp_filenames = setup_ha_and_data(tmp_path)
+    # Do the merge with the test data files
+    output_filename = "output.wav"  # tmp_path / "output.wav"
+    gha_hearing_aid.create_HA_inputs(tmp_filenames, str(output_filename))
+
+    # Read the output file and check it is correct
+    result = gha_hearing_aid.read_signal(output_filename)
+    assert result.shape == (stereo_signal.shape[0], 4)
+    assert np.sum(result) == pytest.approx(np.sum(stereo_signal) * 2.0)
+
+
+@pytest.mark.parametrize(
+    "bad_names, error_type",
+    [
+        (["xxx.CH1.wav", "xxx.CH3.wav"], IndexError),
+        (["xxx.CH1.wav", "x", "xxx.CH1.wav"], ValueError),
+        (["xxx.CH1.wav", "x", "xxx.CH3.wa"], ValueError),
+        (["xxx.CH4.wav", "x", "xxx.CH3.wav"], ValueError),
+    ],
+)
+def test_create_ha_inputs_error(bad_names, error_type):
+    """test that raises errror if file names don't follow the rules"""
+
+    # Must be at least three names and first name must end "1????" and
+    # third name must end "3????". It's a pretty weak test but it
+    # at least prevents channels being supplied in the wrong order
+    # would make more sense if the function constructed the names itself!
+    gha_hearing_aid = GHAHearingAid()
+    with pytest.raises(error_type):
+        gha_hearing_aid.create_HA_inputs(bad_names, "output.wav")
diff --git a/tests/enhancer/gha/test_gha_utils.py b/tests/enhancer/gha/test_gha_utils.py
new file mode 100644
index 000000000..d68ef1d7a
--- /dev/null
+++ b/tests/enhancer/gha/test_gha_utils.py
@@ -0,0 +1,91 @@
+"""Tests for enhancer.gha.gha_utils module"""
+import hashlib
+
+import numpy as np
+import pytest
+
+from clarity.enhancer.gha.audiogram import Audiogram
+from clarity.enhancer.gha.gha_utils import (
+    format_gaintable,
+    get_gaintable,
+    multifit_apply_noisegate,
+)
+
+
+@pytest.fixture
+def gaintable():
+    """fixture for gaintable"""
+    np.random.seed(0)
+    levels_l = np.array([45, 45, 35, 45, 60, 65])
+    levels_r = np.array([45, 45, 35, 45, 60, 65])
+    cfs = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels_l=levels_l, levels_r=levels_r, cfs=cfs)
+    noisegatelevels = np.array([38, 38, 36, 37, 32, 26, 23, 22, 8])
+    noisegateslope = 0.0
+    cr_level = 0.0
+    max_output_level = 100.0
+    this_gaintable = get_gaintable(
+        audiogram=audiogram,
+        noisegatelevels=noisegatelevels,
+        noisegateslope=noisegateslope,
+        cr_level=cr_level,
+        max_output_level=max_output_level,
+    )
+    return this_gaintable
+
+
+def test_get_gaintable(gaintable):
+    """test that the gaintable is generated correctly"""
+
+    # check that the gaintable has the correct keys
+    assert gaintable.keys() == {
+        "sGt_uncorr",
+        "sGt",
+        "noisegatelevel",
+        "noisegateslope",
+        "frequencies",
+        "levels",
+        "channels",
+    }
+    # check that the gaintable has the expected values
+    assert np.sum(np.array(gaintable["sGt_uncorr"])) == pytest.approx(
+        52476.611733926344
+    )
+    assert np.sum(np.array(gaintable["sGt"])) == pytest.approx(45866.4547337617)
+
+
+def test_format_gaintable(gaintable):
+    """test that the gaintable is formatted correctly"""
+
+    # check that the formatted gaintable string has the expected value
+    formatted = format_gaintable(gaintable, noisegate_corr=True)
+    assert (
+        hashlib.md5(formatted.encode("utf-8")).hexdigest()
+        == "d6e6997fd9f1bcaf690d7e6a36813bde"
+    )
+
+    formatted = format_gaintable(gaintable, noisegate_corr=False)
+    assert (
+        hashlib.md5(formatted.encode("utf-8")).hexdigest()
+        == "af02f7ca070ff61a51a1f8572e381818"
+    )
+
+
+def test_multifit_apply_noisegate(gaintable):
+    """test that the noise gate is applied correctly"""
+    INITIAL_SGT_SUM = 52476.611733926344
+    FINAL_SGT_SUM = 45866.4547337617
+    sGt = gaintable["sGt_uncorr"]
+    assert np.sum(sGt) == pytest.approx(INITIAL_SGT_SUM)
+    sFit_model_frequencies = gaintable["frequencies"]
+    sFit_model_levels = gaintable["levels"]
+    noisegate_level = gaintable["noisegatelevel"]
+    noisegate_slope = gaintable["noisegateslope"]
+    signal = multifit_apply_noisegate(
+        sGt, sFit_model_frequencies, sFit_model_levels, noisegate_level, noisegate_slope
+    )
+    # *NB* the returned sGt values equal the sGt variable values...
+    assert np.allclose(signal["sGt"], sGt)
+    # ... but the sGt values have been changed by multifit_apply_noisegate
+    assert np.sum(sGt) != pytest.approx(INITIAL_SGT_SUM)
+    assert np.sum(sGt) == pytest.approx(FINAL_SGT_SUM)
diff --git a/tests/enhancer/test_compressor.py b/tests/enhancer/test_compressor.py
new file mode 100644
index 000000000..7cf2661b5
--- /dev/null
+++ b/tests/enhancer/test_compressor.py
@@ -0,0 +1,47 @@
+import numpy as np
+import pytest
+
+from clarity.enhancer.compressor import Compressor
+
+DEFAULT_FS = 44100
+
+
+def test_compressor_set_attack():
+    """Test that the attack time is set correctly."""
+    c = Compressor()
+    c.set_attack(1000)
+
+    assert c.attack == 1.0 / DEFAULT_FS
+
+
+def test_compressor_set_attack_error():
+    """Test that the attack time raises divide by zero error if set to 0"""
+    c = Compressor()
+    with pytest.raises(ZeroDivisionError):
+        c.set_attack(0)
+
+
+def test_compressor_set_release():
+    """Test that the release time is set correctly."""
+    c = Compressor()
+    c.set_release(1000)
+
+    assert c.release == 1.0 / DEFAULT_FS
+
+
+def test_compressor_set_release_error():
+    """Test that the release time raises divide by zero error if set to 0."""
+    c = Compressor()
+    with pytest.raises(ZeroDivisionError):
+        c.set_release(0)
+
+
+def test_compressor_process():
+    c = Compressor()
+    signal = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
+    output, rms, comp_ratios = c.process(signal)
+
+    assert len(output) == len(signal)
+    assert np.all(rms >= 0.0)
+    assert np.sum(rms) == pytest.approx(0.9799197751960967)
+    assert len(comp_ratios) == len(signal)
diff --git a/tests/enhancer/test_nalr.py b/tests/enhancer/test_nalr.py
index 4da3099bc..16e5c4295 100644
--- a/tests/enhancer/test_nalr.py
+++ b/tests/enhancer/test_nalr.py
@@ -7,24 +7,34 @@
 cfg_nalr = {"nfir": 220, "fs": 44100}
 
 
+STANDARD_CFS = np.array([250, 500, 1000, 2000, 4000, 6000])
+
+
 @pytest.mark.parametrize(
     "audiogram, cfs, expected",
     [
         (
             np.array([45, 45, 35, 45, 60, 65]),
-            np.array([250, 500, 1000, 2000, 4000, 6000]),
-            3.9861248204486366e-05,
+            np.array([250, 500, 1000, 1500, 4000, 6000]),  # <-- non default cfs
+            29.49383298688706,
         ),
         (
             np.array([45, 45, 35, 45, 60, 65]),
             None,
-            3.9861248204486366e-05,
+            28.846583644263408,
         ),
     ],
 )
 def test_nalr(audiogram: np.ndarray, cfs: np.ndarray, expected: float) -> None:
+    """Test that the NALR filter is built correctly."""
     enhancer = NALR(**cfg_nalr)
     nalr_fir, _ = enhancer.build(audiogram, cfs)
-    assert nalr_fir[0] == pytest.approx(
-        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
-    )
+    assert np.sum(np.abs(nalr_fir)) == pytest.approx(expected)
+
+
+def test_nalr_default_cfs() -> None:
+    """Test that the NALR filter is the same for the default and standard cfs."""
+    enhancer = NALR(**cfg_nalr)
+    nalr_fir1, _ = enhancer.build(np.array([45, 45, 35, 45, 60, 65]))
+    nalr_fir2, _ = enhancer.build(np.array([45, 45, 35, 45, 60, 65]), STANDARD_CFS)
+    assert np.allclose(nalr_fir1, nalr_fir2)
diff --git a/tests/evaluator/haaqi/test_haaqi.py b/tests/evaluator/haaqi/test_haaqi.py
index 323eedd0e..04a0a3f19 100644
--- a/tests/evaluator/haaqi/test_haaqi.py
+++ b/tests/evaluator/haaqi/test_haaqi.py
@@ -24,7 +24,11 @@ def test_haaqi_v1() -> None:
     )
 
 
-def test_compute_haaqi():
+@pytest.mark.parametrize(
+    "scale_reference,expected_result",
+    [(False, 0.113759275), (True, 0.114157435)],
+)
+def test_compute_haaqi(scale_reference, expected_result):
     """Test for compute_haaqi function"""
     np.random.seed(42)
 
@@ -42,9 +46,10 @@ def test_compute_haaqi():
         audiogram=audiogram,
         audiogram_frequencies=audiogram_frequencies,
         sample_rate=sample_rate,
+        scale_reference=scale_reference,
     )
 
     # Check that the score is a float between 0 and 1
     assert score == pytest.approx(
-        0.113759275, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+        expected_result, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
diff --git a/tests/evaluator/haspi/test_eb.py b/tests/evaluator/haspi/test_eb.py
index 389a23d60..15228d201 100644
--- a/tests/evaluator/haspi/test_eb.py
+++ b/tests/evaluator/haspi/test_eb.py
@@ -1,5 +1,4 @@
 """Tests for eb module"""
-
 import numpy as np
 import pytest
 
@@ -293,7 +292,7 @@ def test_env_compress_basilar_membrane():
         fsamp=24000,
         level1=140,
         small=1e-30,
-        threshold_high=120,
+        threshold_high=100,
     )
     # check shapes
     assert compressed_signal.shape == (600,)
diff --git a/tests/evaluator/msbg/test_audiogram.py b/tests/evaluator/msbg/test_audiogram.py
index 73f44cf5c..4de721e83 100644
--- a/tests/evaluator/msbg/test_audiogram.py
+++ b/tests/evaluator/msbg/test_audiogram.py
@@ -1,4 +1,45 @@
 """Tests for audiogram module"""
 
+import numpy as np
+import pytest
 
-# Audiogram
+from clarity.evaluator.msbg.audiogram import (
+    AUDIOGRAM_MILD,
+    AUDIOGRAM_MODERATE,
+    AUDIOGRAM_MODERATE_SEVERE,
+    AUDIOGRAM_REF,
+    Audiogram,
+)
+
+
+def test_audiogram():
+    """Test Audiogram class"""
+    levels = np.array([60, 70, 80])
+    cfs = np.array([250, 500, 1000])
+    audiogram = Audiogram(levels=levels, cfs=cfs)
+    assert np.all(audiogram.levels == levels)
+    assert np.all(audiogram.cfs == cfs)
+
+
+@pytest.mark.parametrize(
+    "cfs, levels, severity",
+    [
+        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 10, 10]), "NOTHING"),
+        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 20, 20]), "MILD"),
+        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 40, 40]), "MODERATE"),
+        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 80, 80]), "SEVERE"),
+        (np.array([500, 800, 900, 1000]), np.array([60, 70, 80, 90]), "NOTHING"),
+        (np.array([9000, 10000]), np.array([120, 120]), "NOTHING"),
+    ],
+)
+def test_audiogram_severity(cfs, levels, severity):
+    """Test Audiogram severity calculation"""
+    assert Audiogram(levels=levels, cfs=cfs).severity == severity
+
+
+def test_standard_audiograms_are_correct_severity():
+    """Test standard audiogram"""
+    assert AUDIOGRAM_REF.severity == "NOTHING"
+    assert AUDIOGRAM_MILD.severity == "MILD"
+    assert AUDIOGRAM_MODERATE.severity == "MODERATE"
+    assert AUDIOGRAM_MODERATE_SEVERE.severity == "SEVERE"
diff --git a/tests/evaluator/msbg/test_cochlea.py b/tests/evaluator/msbg/test_cochlea.py
new file mode 100644
index 000000000..9aa764176
--- /dev/null
+++ b/tests/evaluator/msbg/test_cochlea.py
@@ -0,0 +1,160 @@
+"""Tests for cochlea module"""
+
+import numpy as np
+import pytest
+
+from clarity.evaluator.msbg.audiogram import Audiogram
+from clarity.evaluator.msbg.cochlea import (
+    Cochlea,
+    FilterBank,
+    compute_envelope,
+    compute_recruitment_parameters,
+    gammatone_filterbank,
+    recruitment,
+)
+
+
+def test_filterbank():
+    """Test Filterbank class"""
+    nums = np.array([4, 5, 6])
+    denoms = np.array([1, 2, 3])
+    filter_bank = FilterBank(nums=nums, denoms=denoms)
+    assert np.all(filter_bank.nums == nums)
+    assert np.all(filter_bank.denoms == denoms)
+
+
+@pytest.mark.parametrize(
+    "center_freqs, cf_expansion_expected",
+    [
+        (
+            np.array([250, 500, 1000]),
+            np.array([-0.0256410256410, -0.02189781021897, -0.019108280254]),
+        ),
+        (
+            np.array([150, 500, 1000]),  # extrapolate lower
+            np.array([-0.0256410256410, -0.02189781021897, -0.019108280254]),
+        ),
+        (
+            np.array([250, 500, 2000]),  # extrapolate higher
+            np.array([-0.0256410256410, -0.02189781021897, -0.01910828025477707]),
+        ),
+    ],
+)
+def test_compute_recruitment_parameters(center_freqs, cf_expansion_expected):
+    """Test compute_recruitment_parameters"""
+    audiogram_cfs = np.array([250, 500, 1000])
+    levels = np.array([60, 70, 80])
+    n_channels = len(levels)
+    catch_up = 1.5
+    audiogram = Audiogram(levels=levels, cfs=audiogram_cfs)
+    cf_expansion, eq_loud_db_catch_up = compute_recruitment_parameters(
+        gtn_cf=center_freqs,
+        audiogram=audiogram,
+        catch_up=catch_up,
+    )
+    assert cf_expansion.shape == (n_channels,)
+    assert eq_loud_db_catch_up.shape == (n_channels,)
+    assert cf_expansion == pytest.approx(cf_expansion_expected)
+    assert eq_loud_db_catch_up == pytest.approx(np.array([catch_up] * n_channels))
+
+
+def test_gammatone_filterbank():
+    """Test gammatone_filterbank"""
+
+    np.random.seed(0)
+    signal = np.random.random(1000)
+    levels = np.array([30, 40, 40])
+    audiogram_cfs = np.array([250, 500, 2000])
+    audiogram = Audiogram(levels=levels, cfs=audiogram_cfs)
+
+    cochlea = Cochlea(audiogram=audiogram)
+
+    coch_sig_out = gammatone_filterbank(
+        x=signal,
+        ngamma=cochlea.gtfbank_params["NGAMMA"],
+        gtn_filters=FilterBank(
+            cochlea.gtfbank_params["GTn_nums"], cochlea.gtfbank_params["GTn_denoms"]
+        ),
+        gtn_delays=cochlea.gtfbank_params["GTnDelays"],
+        start2poleHP=cochlea.gtfbank_params["Start2PoleHP"],
+        hp_filters=FilterBank(
+            cochlea.gtfbank_params["HP_nums"], cochlea.gtfbank_params["HP_denoms"]
+        ),
+    )
+    assert coch_sig_out.shape == (28, len(signal))
+    assert np.sum(np.abs(coch_sig_out)) == pytest.approx(1327.2811434076052)
+
+
+def test_compute_envelope():
+    """Test compute_envelope"""
+    np.random.seed(0)
+    sample_rate = 44100
+    # ERB spaced filter centre frequencies in Hz
+    erbn_cf = np.array([250, 500, 1000])
+    n_channels = len(erbn_cf)
+    signal = np.random.random((n_channels, 1000))
+    result = compute_envelope(coch_sig=signal, erbn_cf=erbn_cf, fs=sample_rate)
+    assert result.shape == (n_channels, 1000)
+    assert np.sum(np.abs(result)) == pytest.approx(1444.2528021105204)
+
+
+def test_recruitment():
+    """Test recruitment"""
+    np.random.seed(0)
+    sample_rate = 44100
+
+    erbn_cf = np.array([250, 500, 1000])
+    n_channels = len(erbn_cf)
+    signal = np.random.random((n_channels, 1000))
+    expansion_ratios = np.array([1.0, 1.0, 1.0])
+    eq_loud_db = np.array([60.0, 70.0, 80.0])
+
+    envelope = compute_envelope(coch_sig=signal, erbn_cf=erbn_cf, fs=sample_rate)
+    result = recruitment(
+        coch_sig=signal,
+        envelope=envelope,
+        SPL_equiv_0dB=100.0,
+        expansion_ratios=expansion_ratios,
+        eq_loud_db=eq_loud_db,
+    )
+    assert result.shape == (n_channels, 1000)
+    assert np.sum(np.abs(result)) == pytest.approx(1512.7196632453372)
+
+
+@pytest.mark.parametrize(
+    "levels, severity, has_smearer",
+    [
+        (np.array([30, 40, 10]), "NOTHING", False),
+        (np.array([30, 40, 20]), "MILD", True),
+        (np.array([30, 40, 40]), "MODERATE", True),
+        (np.array([30, 40, 80]), "SEVERE", True),
+    ],
+)
+def test_cochlea(levels, severity, has_smearer):
+    """Test Cochlea class"""
+    audiogram_cfs = np.array([250, 500, 2000])
+    audiogram = Audiogram(levels=levels, cfs=audiogram_cfs)
+    assert audiogram.severity == severity
+
+    cochlea = Cochlea(audiogram=audiogram)
+    assert (cochlea.smearer is not None) == has_smearer
+
+
+@pytest.mark.parametrize(
+    "levels, expected, n_samples_out",
+    [
+        (np.array([30, 40, 10]), 140.40357187610562, 1000),
+        (np.array([30, 40, 40]), 42.38705275894786, 1216),  # smearer adds samples
+    ],
+)
+def test_cochlea_simulate(levels, expected, n_samples_out):
+    """Test Cochlea class"""
+    np.random.seed(0)
+    signal = np.random.random(1000)
+    audiogram_cfs = np.array([250, 500, 2000])
+    audiogram = Audiogram(levels=levels, cfs=audiogram_cfs)
+
+    cochlea = Cochlea(audiogram=audiogram)
+    result = cochlea.simulate(coch_sig=signal, equiv_0dB_file_SPL=100.0)
+    assert result.shape == (n_samples_out,)
+    assert np.sum(np.abs(result)) == pytest.approx(expected)
diff --git a/tests/evaluator/msbg/test_cochlear.py b/tests/evaluator/msbg/test_cochlear.py
deleted file mode 100644
index ef8e46c4f..000000000
--- a/tests/evaluator/msbg/test_cochlear.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""Tests for cochlear module"""
-
-# Filterbank
-
-# compute_recruitment_parameters
-
-# gammatone_filterbank
-
-# compute_envelope
-
-
-# recruitment
-
-
-# Cochlear
-
-# simulate
diff --git a/tests/evaluator/msbg/test_msbg.py b/tests/evaluator/msbg/test_msbg.py
index 50a7ecc23..1347cd8f9 100644
--- a/tests/evaluator/msbg/test_msbg.py
+++ b/tests/evaluator/msbg/test_msbg.py
@@ -1,15 +1,140 @@
 """Tests for msbg module"""
 
+import numpy as np
+import pytest
 
-# Ear
+from clarity.evaluator.msbg.audiogram import AUDIOGRAM_MODERATE_SEVERE, Audiogram
+from clarity.evaluator.msbg.msbg import Ear
+from clarity.evaluator.msbg.msbg_utils import DF_ED, FF_ED
 
-# get_src_correction
 
-# src_to_cochlear_filter
+def test_ear():
+    """Test Ear constructor"""
+    ear = Ear()
+    assert ear.cochlea is None
+    assert ear.ahr == 20
 
 
-# make_calibration_signal
+def test_set_audiogram():
+    """Test Ear.set_audiogram"""
+    ear = Ear()
+    ear.set_audiogram(audiogram=AUDIOGRAM_MODERATE_SEVERE)
+    assert ear.cochlea is not None
 
-# array_to_list
+    # Bad audiogram - will log a warning
+    audiogram_bad = Audiogram(levels=np.array([150]), cfs=np.array([4000]))
+    ear.set_audiogram(audiogram=audiogram_bad)
+    assert ear.cochlea is not None
 
-# process
+
+def test_get_src_correction():
+    """Test Ear.get_src_correction"""
+    correction = Ear.get_src_correction("ff")
+    assert np.all(correction == FF_ED)
+    correction = Ear.get_src_correction("df")
+    assert np.all(correction == DF_ED)
+    correction = Ear.get_src_correction("ITU")
+    assert np.sum(correction) == pytest.approx(215.69444085326438)
+
+
+def test_get_src_correction_error():
+    """Test Ear.get_src_correction with invalid src_pos"""
+    with pytest.raises(ValueError):
+        Ear.get_src_correction("bad")
+
+
+def test_src_to_cochlea_filter():
+    """Test Ear.src_to_cochlea_filter"""
+    np.random.seed(0)
+    ip_sig = np.random.rand(1000)
+    signal = Ear.src_to_cochlea_filt(
+        ip_sig=ip_sig,
+        src_correction=Ear.get_src_correction("ff"),
+        sample_frequency=16000,
+        backward=False,
+    )
+    assert signal.shape == (1000,)
+    assert np.sum(np.abs(signal)) == pytest.approx(285.70213562623803)
+
+
+def test_make_calibration_signal():
+    """Test Ear.make_calibration_signal"""
+    np.random.seed(0)
+    ear = Ear()
+    signal, silence = ear.make_calibration_signal(ref_rms_db=60)
+    assert signal.shape == (44100 * 2.65,)  # 2.65 s signal
+    assert silence.shape == (44100 * 0.05,)  # 50 ms silence
+    assert np.sum(np.abs(silence)) == pytest.approx(0.0)
+    assert np.sum(np.abs(signal)) == pytest.approx(90853246.9096005)
+
+
+@pytest.mark.parametrize("signal_shape", [((100, 4)), ((100, 2)), ((100,))])
+def test_array_to_list(signal_shape):
+    """Test Ear.array_to_list"""
+    array_of_signals = np.random.random(signal_shape)
+    list_of_signals = Ear.array_to_list(array_of_signals)
+    if len(signal_shape) > 1:
+        # Check signals in list match the columns in the orignal array
+        assert len(list_of_signals) == signal_shape[1]
+        for i, signal in enumerate(list_of_signals):
+            assert np.all(array_of_signals[:, i] == signal)
+    else:
+        # Special case for 1-D array
+        assert np.all(array_of_signals == list_of_signals[0])
+
+
+def test_ear_process():
+    """Test ear.process"""
+    np.random.seed(0)
+
+    signal = np.random.rand(500)
+
+    ear = Ear()
+    # Can process with audiogram
+    processed_signals = ear.process(signal)
+    assert len(processed_signals) == 1
+    assert processed_signals[0].shape == (500,)  # same length as input signal
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(0.3250822358794586)
+
+    ear.set_audiogram(audiogram=AUDIOGRAM_MODERATE_SEVERE)
+
+    processed_signals = ear.process(signal)
+    assert len(processed_signals) == 1
+    assert processed_signals[0].shape == (704,)  # longer than input due to smearing
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(0.5448164192836198)
+
+    signal2 = np.random.rand(500, 2)
+    processed_signals = ear.process(signal2)
+    assert len(processed_signals) == 2
+    assert processed_signals[0].shape == (704,)
+    assert processed_signals[1].shape == (704,)
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(0.4983800098691009)
+    assert np.sum(np.abs(processed_signals[1])) == pytest.approx(0.514370533984153)
+
+    signal1 = np.random.rand(500, 1)
+    processed_signals = ear.process(signal1)
+    assert len(processed_signals) == 1
+    assert processed_signals[0].shape == (704,)
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(0.5840767608116147)
+
+    signal = np.random.rand(500)
+    processed_signals = ear.process(signal, add_calibration=True)
+    assert len(processed_signals) == 1
+    assert processed_signals[0].shape == (119808,)
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(48480.61370282146)
+
+
+def test_ear_process_error():
+    """Test ear.process with invalid sample frequency"""
+    signal = np.random.rand(500)
+
+    ear = Ear()
+
+    # Try processing before audiogram is set
+    ear.process(signal)
+
+    # Try processing with invalid sample frequency
+    ear.set_audiogram(audiogram=AUDIOGRAM_MODERATE_SEVERE)
+    ear.sample_frequency = 16000
+    with pytest.raises(ValueError):
+        ear.process(signal)
diff --git a/tests/evaluator/msbg/test_msbg_utils.py b/tests/evaluator/msbg/test_msbg_utils.py
index 8521abb87..e742317b3 100644
--- a/tests/evaluator/msbg/test_msbg_utils.py
+++ b/tests/evaluator/msbg/test_msbg_utils.py
@@ -1,16 +1,157 @@
 """Tests for msbg_utils module"""
+import numpy as np
+import pytest
 
+from clarity.evaluator.msbg.msbg_utils import (
+    firwin2,
+    gen_eh2008_speech_noise,
+    gen_tone,
+    measure_rms,
+    pad,
+    read_gtf_file,
+    read_signal,
+    write_signal,
+)
 
-# read_gtf_file
+# import scipy
 
 
-# firwin2
+def test_read_gtf_file():
+    """Test reading of the gammatone filter file"""
+    gtf = read_gtf_file("msbg_hparams/GT4FBank_Brd1.5E_Spaced1.1E_44100Fs.json")
+    # Check the values are of expected types (NB all lists converted to ndarrays)
+    for record in gtf:
+        assert type(record) in {int, float, str, np.ndarray}
+    # Check the gtf dict has the expected keys
+    assert set(gtf.keys()) == {
+        "__version__",
+        "__globals__",
+        "Fs",
+        "BROADEN",
+        "SPACING",
+        "NGAMMA",
+        "GTnDelays",
+        "GTn_denoms",
+        "GTn_nums",
+        "GTn_CentFrq",
+        "ERBn_CentFrq",
+        "HP_denoms",
+        "HP_nums",
+        "HP_FCorner",
+        "HP_Delays",
+        "NChans",
+        "Start2PoleHP",
+        "Recombination_dB",
+        "DateCreated",
+    }
 
 
-# gen_eh2008_speech_noise
+def test_firwin2():
+    """Test firwin2"""
+    n_taps = 128
+    frequencies = np.array([0.0, 0.1, 0.9, 1.0])
+    filter_gains = np.array([0, 1.0, 1.0, 0.0])
+    params = firwin2(
+        n_taps=n_taps,
+        frequencies=frequencies,
+        filter_gains=filter_gains,
+        window=None,  # ("kaiser", 4),
+    )
+    # _params_scipy = scipy.signal.firwin2(
+    #    numtaps=n_taps,
+    #    freq=frequencies,
+    #    gain=filter_gains,
+    #    window=None,  # ("kaiser", 4)
+    # )
+    assert params.shape == (128,)
+    assert np.sum(np.abs(params)) == pytest.approx(2.5662415502127844)
 
-# measure_rms
+    # TODO: check why the test below fails
+    # I thought this was meant to give something like the scipy version, but it doesn't
+    # assert np.allclose(params, params_scipy)
 
-# read_signal
 
-# write_signal
+def test_gen_tone():
+    """Test gen_tone"""
+    signal = gen_tone(500, 0.1, 44100.0, 20.0)
+    assert signal.shape == (4410,)
+    assert np.sum(np.abs(signal)) == pytest.approx(39703.40087188665)
+
+
+def test_gen_eh2008_speech_noise():
+    """Test gen_eh2008_speech_noise"""
+    np.random.seed(0)
+    signal = gen_eh2008_speech_noise(0.1, 44100.0, 0.0)
+    assert signal.shape == (4410,)
+    assert np.sum(np.abs(signal)) == pytest.approx(3544.690935132768)
+
+
+def test_measure_rms():
+    """Test measure_rms"""
+    np.random.seed(0)
+    signal = gen_tone(500, 0.05, 44100.0, 20.0)
+    noise = gen_eh2008_speech_noise(0.05, 44100.0, 0.0)
+    rms, idx, rel_dB_thresh, active = measure_rms(signal + noise, 44100, 0.0, 10.0)
+    assert rms == pytest.approx(10.723997044548266)
+    assert idx.shape == (441,)
+    assert np.sum(idx) == pytest.approx(97020)
+    assert rel_dB_thresh == pytest.approx(-0.0008828901826447577)
+    assert active == pytest.approx(20.0)
+
+
+@pytest.mark.parametrize(
+    "initial, desired_length",
+    [
+        (np.array([1.0, 1.0, 1.0, 1.0]), 4),  # check can do no change
+        (np.array([]), 100),  # check can pad empty array
+    ],
+)
+def test_pad(initial, desired_length):
+    """Test pad function"""
+    result = pad(initial, desired_length)
+    assert result.shape == (desired_length,)
+    assert np.sum(np.abs(result)) == pytest.approx(np.sum(np.abs(initial)))
+
+
+def test_pad_error():
+    """Test pad function fails when padded length is smaller than input"""
+    with pytest.raises(ValueError):
+        pad(np.array([1.0, 1.0, 1.0, 1.0]), 3)
+
+
+@pytest.mark.parametrize(
+    "signal, floating_point, strict",
+    [
+        (np.array([1.1, -2.0, 0.0, 44.0, -54.0]), True, True),  # float32
+        (np.array([0.1, -0.2, 0.1, -0.5, 0.99]), False, True),  # int16
+        (np.array([0.1, -0.2, 0.1, -1.00, 0.99]), False, True),  # int16
+        (np.array([0.1, -0.2, 0.1, -1.00, 1.99]), False, False),  # int16
+        (np.array([0.1, -0.2, 0.1, -1.00, 0.99]), False, True),  # int16
+    ],
+)
+def test_write_read_loop(tmp_path, signal, floating_point, strict):
+    """Test write_signal and read_signal"""
+    tmp_filename = tmp_path / "test.wav"
+    # signal = np.array([1.1, -2.0, 0.0, 44.0, -54.0])
+    write_signal(
+        tmp_filename, signal, 44100, floating_point=floating_point, strict=strict
+    )
+    result = read_signal(tmp_filename)
+
+    # Some precision is lost as convert to int16 and back again
+    assert result.shape == signal.shape
+    # The test where strict is False has overflow which is not caught and hence
+    # reading back the signal has changed
+    if strict:
+        assert result == pytest.approx(signal, abs=1.0 / 16384)
+    else:
+        # Deliberate fail: shows why strict is True is needed
+        assert result != pytest.approx(signal, abs=1.0 / 16384)
+
+
+def test_write_error(tmp_path):
+    """Test write_signal and read_signal"""
+    tmp_filename = tmp_path / "test.wav"
+    signal = np.array([0.1, -0.1, 1.00])  # sample out of range
+    with pytest.raises(ValueError):
+        write_signal(tmp_filename, signal, 44100, floating_point=False, strict=True)
diff --git a/tests/evaluator/msbg/test_smearing.py b/tests/evaluator/msbg/test_smearing.py
index 490d4d2ff..b1a081c80 100644
--- a/tests/evaluator/msbg/test_smearing.py
+++ b/tests/evaluator/msbg/test_smearing.py
@@ -2,20 +2,42 @@
 import numpy as np
 import pytest
 
-from clarity.evaluator.msbg.smearing import Smearer, make_smear_mat3
+from clarity.evaluator.msbg.smearing import Smearer, audfilt, make_smear_mat3, smear3
 
-# audfilt
+# Default values
+R_LOWER = 0.5  # Lower frequency of the auditory filter
+R_UPPER = 1.5  # Upper frequency of the auditory filter
+SAMPLE_FREQ = 44100  # Sample frequency of the input signal
 
-# make_smear_mat3
 
-# smear3
+def test_audfilt():
+    """Test the auditory filter function"""
+    n_taps = 128
+    filter_params = audfilt(rl=R_LOWER, ru=R_UPPER, sampfreq=SAMPLE_FREQ, asize=n_taps)
+    assert filter_params.shape == (n_taps, n_taps)
+    assert np.sum(np.abs(filter_params)) == pytest.approx(19.879915844855944)
 
-# Smearer
+
+def test_make_smear_mat3_valid_input():
+    """Tests that make_smear_mat3 returns matrix with the correct dimensions"""
+    f_smear = make_smear_mat3(rl=R_LOWER, ru=R_UPPER, fs=SAMPLE_FREQ)
+    assert f_smear.shape == (256, 256)
+    assert np.sum(np.abs(f_smear)) == pytest.approx(2273.976168294156)
+
+
+def test_smear3():
+    """Test smear3"""
+    np.random.seed(0)
+    input_signal = np.random.rand(10000)
+    smear_mat = make_smear_mat3(rl=R_LOWER, ru=R_UPPER, fs=SAMPLE_FREQ)
+    output_signal = smear3(smear_mat, input_signal)
+    assert output_signal.shape == (10240,)
+    assert np.sum(np.abs(output_signal)) == pytest.approx(5066.986397433977)
 
 
 def test_change_filter():
     """Test changing filter broadening factors and sampling frequency"""
-    smearer = Smearer(0.5, 1.5, 44100)
+    smearer = Smearer(rl=R_LOWER, ru=R_UPPER, fs=SAMPLE_FREQ)
     f_smear_old = smearer.f_smear
     smearer.rl = 0.8
     smearer.ru = 1.2
@@ -29,21 +51,9 @@ def test_change_filter():
 def test_smear_valid_input():
     """Test smear with valid input"""
     np.random.seed(0)
-    r_lower = 0.5
-    r_upper = 1.5
-    sample_freq = 44100
     input_signal = np.random.rand(10000)
-    output_signal = Smearer(r_lower, r_upper, sample_freq).smear(input_signal)
+    output_signal = Smearer(rl=R_LOWER, ru=R_UPPER, fs=SAMPLE_FREQ).smear(input_signal)
     assert output_signal.shape == (10240,)
     assert np.sum(np.abs(output_signal)) == pytest.approx(
         5066.986397433977, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
-
-
-def test_make_smear_mat3_valid_input():
-    """Tests that make_smear_mat3 returns matrix with the correct dimensions"""
-    r_lower = 0.5
-    r_upper = 1.5
-    sample_freq = 44100
-    f_smear = make_smear_mat3(rl=r_lower, ru=r_upper, fs=sample_freq)
-    assert f_smear.shape == (256, 256)
diff --git a/tests/predictor/test_torch_msbg.py b/tests/predictor/test_torch_msbg.py
new file mode 100644
index 000000000..0e2a9c833
--- /dev/null
+++ b/tests/predictor/test_torch_msbg.py
@@ -0,0 +1,213 @@
+"""Module for testing torch_msbg.py"""
+import numpy as np
+import pytest
+import torch
+
+from clarity.evaluator.msbg.audiogram import (
+    AUDIOGRAM_MILD,
+    AUDIOGRAM_MODERATE,
+    AUDIOGRAM_MODERATE_SEVERE,
+)
+from clarity.predictor.torch_msbg import MSBGHearingModel, torchloudnorm
+
+
+@pytest.fixture
+def use_torch():
+    """Fixture to ensure torch is used"""
+    torch.manual_seed(0)
+    torch.set_num_threads(1)
+    # torch.set_num_interop_threads(1)
+    torch.set_default_tensor_type(torch.FloatTensor)
+
+
+@pytest.fixture
+def msbg_model():
+    """MSBG model fixture"""
+    model = MSBGHearingModel(
+        audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+    )
+    return model
+
+
+@pytest.fixture
+def msbg_model_quick():
+    """MSBG model fixture with smaller kernel for quick testing"""
+    model = MSBGHearingModel(
+        audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+        kernel_size=129,
+    )
+    return model
+
+
+# Tests for MSBGHearingModel class
+
+
+def test_msbg_hearing_model_init(use_torch):
+    """Test the MSBGHearingModel class init function"""
+    model = MSBGHearingModel(
+        audiogram=AUDIOGRAM_MILD.levels.tolist(),
+        audiometric=AUDIOGRAM_MILD.cfs.tolist(),
+    )
+    assert model.win_len == 441
+    model = MSBGHearingModel(
+        audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+    )
+    assert model.win_len == 441
+    model = MSBGHearingModel(
+        audiogram=AUDIOGRAM_MODERATE_SEVERE.levels.tolist(),
+        audiometric=AUDIOGRAM_MODERATE_SEVERE.cfs.tolist(),
+    )
+    assert model.win_len == 441
+    model = MSBGHearingModel(
+        audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+    )
+    assert model.win_len == 441
+
+
+def test_msbg_hearing_model_measure_rms(use_torch, msbg_model):
+    """Test the measure_rms function"""
+    x = torch.randn(2, 20000)
+    x = x.cpu()
+    y_torch = msbg_model.measure_rms(x)
+    y = y_torch.cpu().detach().numpy()
+    assert y.shape == (2, 1)
+    assert y[0, 0] == pytest.approx(1.0040439)
+    assert y[1, 0] == pytest.approx(0.99920964)
+
+
+def test_msbg_hearing_model_calibrate_spl(use_torch, msbg_model):
+    """Test the calibrate_spl function"""
+    x = torch.randn(2, 20000)
+    x = x.cpu()
+    y_torch = msbg_model.calibrate_spl(x)
+    y = y_torch.cpu().detach().numpy()
+    assert y.shape == (2, 20000)
+    assert np.sum(np.abs(y)) == pytest.approx(32026.688)
+
+
+def test_msbg_hearing_model_calibrate_spl_null(use_torch):
+    """Test the calibrate_spl function does nothing is calibration is disabled"""
+    this_msbg_model = MSBGHearingModel(
+        audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+        spl_cali=False,  # <--- Disable calibration
+    )
+    x = torch.randn(2, 20000)
+    x = x.cpu()
+    initial_sum = np.sum(np.abs(x.detach().numpy()))
+    y_torch = this_msbg_model.calibrate_spl(x)
+    y = y_torch.cpu().detach().numpy()
+    assert y.shape == (2, 20000)
+    assert np.sum(np.abs(y)) == pytest.approx(initial_sum)  # <--- No change
+
+
+def test_msbg_hearing_model_src_to_cochlea_filt(use_torch, msbg_model):
+    """Test the src_to_cochlea_filt function"""
+    x = torch.randn(1, 20000)
+    x = x.cpu()
+    y_torch = msbg_model.src_to_cochlea_filt(x, msbg_model.cochlea_filter_forward)
+    y = y_torch.cpu().detach().numpy()
+    assert y.shape == (1, 20000)
+    assert np.sum(np.abs(y)) == pytest.approx(14804.21875)
+
+
+def test_msbg_hearing_model_smear(use_torch, msbg_model):
+    """Test the smear function"""
+    x = torch.randn(2, 1, 20000)
+    x = x.cpu()
+    y_torch = msbg_model.smear(x)
+    y = y_torch.cpu().detach().numpy()
+    assert y.shape == (2, 1, 20000)
+    assert np.sum(np.abs(y)) == pytest.approx(29985.154)
+
+
+def test_msbg_hearing_model_recruitment(use_torch, msbg_model_quick):
+    """Test the recruitment function"""
+    x = torch.randn(2, 1, 10000)
+    x = x.cpu()
+    y_torch = msbg_model_quick.recruitment(x)
+    y = y_torch.cpu().detach().numpy()
+    assert y.shape == (2, 1, 10000)
+    assert np.sum(np.abs(y)) == pytest.approx(13046.6328125)
+
+
+def test_msbg_hearing_model_recruitment_fir(use_torch, msbg_model_quick):
+    """Test the recruitment_fir function"""
+    x = torch.randn(1, 10000)
+    x = x.cpu()
+    y_torch = msbg_model_quick.recruitment_fir(x)
+    y = y_torch.cpu().detach().numpy()
+    assert y.shape == (1, 1, 10000)
+    assert np.sum(np.abs(y)) == pytest.approx(5229.41162109375)
+
+
+def test_msbg_hearing_model_forward(use_torch, msbg_model_quick):
+    """Test the forward function"""
+    x = torch.randn(2, 10000)
+    x = x.cpu()
+    y_torch = msbg_model_quick.forward(x)
+    y = y_torch.cpu().detach().numpy()
+    assert y.shape == (2, 10000)
+    assert np.sum(np.abs(y)) == pytest.approx(8778.4501953125)
+
+
+# Tests for torchloudnorm class
+
+
+def test_torchloudnorm_apply_filter(use_torch):
+    """Test torchloudnorm apply filter function"""
+
+    x = torch.randn(2, 1, 40000)
+    x = x.cpu()
+    loud_norm = torchloudnorm()
+
+    y_torch = loud_norm.apply_filter(x)
+    y = y_torch.cpu().detach().numpy()
+
+    assert y.shape == (2, 1, 40000)
+    assert np.sum(np.abs(y)) == pytest.approx(98578.4921875)
+
+
+def test_torchloudnorm_integrated_loudness(use_torch):
+    """Test torchloudnorm integrated loundness function"""
+
+    x = torch.randn(2, 1, 20000)
+    x = x.cpu()
+    loud_norm = torchloudnorm()
+
+    y_torch = loud_norm.integrated_loudness(x)
+    y = y_torch.cpu().detach().numpy()
+
+    assert y.shape == (2, 1)
+    assert np.sum(np.abs(y)) == pytest.approx(6.166536331176758)
+
+
+def test_torchloudnorm_normalise_loudness(use_torch):
+    """Test torchloudnorm normalise loudness function"""
+
+    lufs = torch.FloatTensor([[-30.0], [-40.0]]).cpu()
+    x = torch.randn(2, 20000)
+    x = x.cpu()
+    loud_norm = torchloudnorm()
+    y_torch = loud_norm.normalize_loudness(x, lufs=lufs)
+    y = y_torch.cpu().detach().numpy()
+
+    assert y.shape == (2, 20000)
+    assert np.sum(np.abs(y)) == pytest.approx(33388.645)
+    assert True
+
+
+def test_torchloudnorm_forward(use_torch):
+    """Test torchloudnorm forward function"""
+
+    x = torch.randn(2, 20000)
+    x = x.cpu()
+    loud_norm = torchloudnorm()
+    y_torch = loud_norm.forward(x)  # i.e. sane as `y_torch = loud_norm(x)`
+    y = y_torch.cpu().detach().numpy()
+    assert y.shape == (2, 20000)
+    assert np.sum(np.abs(y)) == pytest.approx(355.77362060546875)
diff --git a/tests/predictor/test_torch_stoi.py b/tests/predictor/test_torch_stoi.py
new file mode 100644
index 000000000..faf4ce2d2
--- /dev/null
+++ b/tests/predictor/test_torch_stoi.py
@@ -0,0 +1,176 @@
+"""Module for testing torch_stoi.py"""
+import numpy as np
+import pytest
+import torch
+from pystoi.stoi import FS
+from torch.testing import assert_close
+
+from clarity.predictor.torch_stoi import (
+    NegSTOILoss,
+    masked_mean,
+    masked_norm,
+    meanvar_norm,
+)
+
+
+@pytest.fixture
+def use_torch():
+    """Fixture to ensure torch is used"""
+    torch.manual_seed(0)
+    torch.set_num_threads(1)
+    torch.set_default_tensor_type(torch.FloatTensor)
+    torch.set_printoptions(precision=10)
+
+
+def test_negstoi_loss_init(use_torch):
+    """test negstoi loss constructed with various switch settings"""
+    model = NegSTOILoss(sample_rate=FS * 2)
+    assert model.sample_rate == FS * 2
+    model = NegSTOILoss(sample_rate=FS * 2, use_vad=False)
+    assert model.sample_rate == FS * 2
+    model = NegSTOILoss(sample_rate=FS * 2, extended=True)
+    assert model.sample_rate == FS * 2
+    model = NegSTOILoss(sample_rate=FS * 2, do_resample=True)
+    assert model.sample_rate == FS * 2
+
+
+@pytest.mark.parametrize(
+    "seed1, seed2, use_vad, extended, expected",
+    [
+        (0, 0, True, False, -1.0),  # <- score -1 when targets and estimates are equal
+        (0, 0, False, False, -1.0),
+        (0, 0, False, True, -1.0),
+        (0, 1, True, False, -0.0182733461),
+        (0, 1, False, False, -0.0182733461),
+        (0, 1, False, True, -0.0279680751),
+    ],
+)
+def test_negstoi_loss_forward(use_torch, seed1, seed2, use_vad, extended, expected):
+    """test negstoi loss forward"""
+    torch.manual_seed(0)
+    model = NegSTOILoss(sample_rate=16000, use_vad=use_vad, extended=extended)
+    torch.manual_seed(seed1)
+    estimated_targets = torch.rand(1, 16000)
+    torch.manual_seed(seed2)
+    targets = torch.rand(1, 16000)
+    loss = model(estimated_targets, targets)
+    assert loss.shape == (1,)
+    assert_close(loss, torch.tensor([expected]))
+
+
+def test_negstoi_loss_forward_2d(use_torch):
+    """test negstoi loss forward with 2d targets"""
+    torch.manual_seed(0)
+    model = NegSTOILoss(sample_rate=16000)
+    estimated_targets = torch.rand(2, 16000)
+    targets = torch.rand(2, 16000)
+    loss = model(estimated_targets, targets)
+    assert loss.shape == torch.Size([2])
+    assert_close(loss, torch.tensor([0.0437693633, 0.0397059321]))
+
+
+def test_negstoi_loss_detect_silent_frame(use_torch):
+    """test negstoi loss detect silent frame"""
+    torch.manual_seed(0)
+    signal = torch.rand(1, 16000)
+    dyn_range = 1.0
+    framelen = 64
+    hop = 32
+    mask = NegSTOILoss.detect_silent_frames(
+        x=signal,
+        dyn_range=dyn_range,
+        framelen=framelen,
+        hop=hop,
+    )
+    assert mask.shape == torch.Size([1, 1, 498])
+    assert mask.cpu().detach().numpy().sum() == 155
+
+
+def test_negstoi_loss_stft(use_torch):
+    """test negstoi loss stft"""
+    torch.manual_seed(0)
+    fft_size = 16
+    win = torch.hann_window(fft_size)
+    signal = torch.rand(1, 16000)
+    stft = NegSTOILoss.stft(x=signal, win=win, fft_size=fft_size, overlap=4)
+    assert stft.shape == torch.Size([1, 9, 3998, 2])
+    assert stft.cpu().detach().numpy().sum() == pytest.approx(8025.33251953125)
+
+
+def test_negstoi_loss_rowcolnorn(use_torch):
+    """test negstoi loss rowcolnorn"""
+    torch.manual_seed(0)
+    x_size = [3, 100, 100]
+    x = torch.rand(x_size)
+    y = NegSTOILoss.rowcol_norm(x)
+    assert y.shape == torch.Size(x_size)
+    y_array = y.cpu().detach().numpy()
+    assert np.sum(np.abs(y_array)) == pytest.approx(2589.76416015625)
+
+
+def test_meanvar_norm(use_torch):
+    """test meanvar norm"""
+    torch.manual_seed(0)
+    x = torch.rand(1, 100)
+    y = meanvar_norm(x)
+    assert_close(y.abs().sum(), torch.tensor(8.8223075867))
+    # Masking values should change the result
+    y_mask = meanvar_norm(x, mask=x > 0.5)
+    assert_close(y_mask.abs().sum(), torch.tensor(32.58656692504883))
+    # Mask above some very low value, i.e use all x, so same as no mask
+    y_all = meanvar_norm(x, mask=x > -500)
+    assert_close(y_all, y)
+    # Mask above some high low value, i.e use no value, gives very large number
+    # from division by (zero + EPS)
+    y_none = meanvar_norm(x, mask=x > 500)
+    assert_close(y_none.abs().sum(), torch.tensor(4884195328.0))
+
+
+def test_masked_mean(use_torch):
+    """test masked mean"""
+    torch.manual_seed(0)
+    x = torch.rand(1, 100)
+    y = masked_mean(x)
+    assert_close(y.abs().sum(), torch.tensor(0.4884195327758789))
+    # Masking values should change the result
+    y_mask = masked_mean(x, mask=x > 0.5)
+    assert_close(y_mask.abs().sum(), torch.tensor(0.7467631697654724))
+    # Mask above some very low value, i.e use all x, so same as no mask
+    y_all = masked_mean(x, mask=x > -500)
+    assert_close(y_all, y)
+    # Mask above some high low value, i.e use no value, should give 0
+    y_none = masked_mean(x, mask=x > 500)
+    assert_close(y_none.abs().sum(), torch.tensor(0.0))
+
+
+def test_masked_norm(use_torch):
+    """test masked norm"""
+    torch.manual_seed(0)
+    x = torch.rand(1, 100)
+    y = masked_norm(x)
+    assert_close(y.abs().sum(), torch.tensor(5.6958699226379395))
+    # Masking values should change the result
+    y_mask = masked_norm(x, mask=x > 0.5)
+    assert_close(y_mask.abs().sum(), torch.tensor(5.369431972503662))
+    # Mask above some very low value, i.e use all x, so same as no mask
+    y_all = masked_norm(x, mask=x > -500)
+    assert_close(y_all, y)
+    # Mask above some high low value, i.e use no value, should give 0
+    y_none = masked_norm(x, mask=x > 500)
+    assert_close(y_none.abs().sum(), torch.tensor(0.0))
+
+
+def test_masked_mean_keep_dims(use_torch):
+    """test masked norm"""
+    torch.manual_seed(0)
+    x = torch.rand(2, 100, 100)
+    # with keepdim=False the final dimension is dropped...
+    y1 = masked_mean(x, keepdim=False)
+    assert y1.shape == torch.Size([2, 100])
+    # ... with True it is kept but will have a size of 1
+    y2 = masked_mean(x, keepdim=True)
+    assert y2.shape == torch.Size([2, 100, 1])
+    # ... shapes different but the sums should be the same
+    assert_close(y1.abs().sum(), y2.abs().sum())
+    # ... and the values should be the same
+    assert_close(y1, y2.squeeze(-1))
diff --git a/tests/recipes/cad1/task1/test_enhance_task1.py b/tests/recipes/cad1/task1/test_enhance_task1.py
index 15008a010..0a5b37bf5 100644
--- a/tests/recipes/cad1/task1/test_enhance_task1.py
+++ b/tests/recipes/cad1/task1/test_enhance_task1.py
@@ -1,21 +1,23 @@
 """Tests for the enhance module"""
-# pylint: disable=import-error
-
 from pathlib import Path
 
 import numpy as np
+import pytest
 import torch
+from omegaconf import DictConfig
 from torchaudio.pipelines import HDEMUCS_HIGH_MUSDB
 
 from clarity.enhancer.compressor import Compressor
 from clarity.enhancer.nalr import NALR
 from recipes.cad1.task1.baseline.enhance import (
     apply_baseline_ha,
+    clip_signal,
     decompose_signal,
     get_device,
     map_to_dict,
     process_stems_for_listener,
     separate_sources,
+    to_16bit,
 )
 
 BASE_DIR = Path.cwd()
@@ -38,20 +40,37 @@ def test_map_to_dict():
     assert output == expected_output
 
 
-def test_decompose_signal():
+@pytest.mark.parametrize("separation_model", ["demucs", "openunmix"])
+def test_decompose_signal(separation_model):
     """Takes a signal and decomposes it into VDBO sources using the HDEMUCS model"""
     np.random.seed(123456789)
     # Load Separation Model
-    model = HDEMUCS_HIGH_MUSDB.get_model().double()
+    if separation_model == "demucs":
+        model = HDEMUCS_HIGH_MUSDB.get_model().double()
+    elif separation_model == "openunmix":
+        model = torch.hub.load("sigsep/open-unmix-pytorch", "umxhq").double()
+
     device = torch.device("cpu")
     model.to(device)
 
     # Create a mock signal to decompose
-    sample_rate = 8000
-    duration = 1
-    signal = np.random.uniform(size=(1, 2, sample_rate * duration))
+    sample_rate = 44100
+    duration = 0.5
+    signal = np.random.uniform(size=(1, 2, int(sample_rate * duration)))
+
+    # config
+    config = DictConfig(
+        {
+            "sample_rate": sample_rate,
+            "separator": {
+                "model": "demucs",
+                "sources": ["drums", "bass", "other", "vocals"],
+            },
+        }
+    )
     # Call the decompose_signal function and check that the output has the expected keys
     output = decompose_signal(
+        config,
         model,
         signal,
         sample_rate,
@@ -59,9 +78,8 @@ def test_decompose_signal():
         left_audiogram=np.ones(9),
         right_audiogram=np.ones(9),
     )
-
     expected_results = np.load(
-        RESOURCES / "test_enhance.test_decompose_signal.npy",
+        RESOURCES / f"test_enhance.test_decompose_signal_{separation_model}.npy",
         allow_pickle=True,
     )[()]
 
@@ -112,7 +130,7 @@ def test_process_stems_for_listener():
     )
 
     # Call the process_stems_for_listener function and check output is as expected
-    output = process_stems_for_listener(
+    output_stems = process_stems_for_listener(
         stems, enhancer, compressor, audiogram_left, audiogram_right, cfs
     )
     expected_results = np.load(
@@ -120,7 +138,7 @@ def test_process_stems_for_listener():
         allow_pickle=True,
     )[()]
 
-    for key, item in output.items():
+    for key, item in output_stems.items():
         np.testing.assert_array_almost_equal(item, expected_results[key])
 
 
@@ -177,3 +195,27 @@ def test_get_device():
         else torch.device("cpu")
     )
     assert device_type == "cuda" if torch.cuda.is_available() else "cpu"
+
+
+def test_to_16bit():
+    # Generate a random signal
+    signal = np.random.uniform(low=-1.0, high=1.0, size=50)
+    signal_16bit = to_16bit(signal)
+
+    assert np.all(np.abs(signal_16bit) <= 32768)
+
+
+def test_clip_signal():
+    # Generate a random signal
+    np.random.seed(0)
+    signal = np.random.uniform(low=-2.0, high=2.0, size=50)
+
+    # Test with soft clipping
+    clipped_signal, n_clipped = clip_signal(signal, soft_clip=True)
+    assert max(np.abs(clipped_signal)) <= 1.0
+    assert n_clipped == 0
+
+    # Test without soft clipping
+    clipped_signal, n_clipped = clip_signal(signal, soft_clip=False)
+    assert max(np.abs(clipped_signal)) <= 1.0
+    assert n_clipped == 22
diff --git a/tests/recipes/cad1/task1/test_evaluate.py b/tests/recipes/cad1/task1/test_evaluate.py
index 422a4ad04..800618e54 100644
--- a/tests/recipes/cad1/task1/test_evaluate.py
+++ b/tests/recipes/cad1/task1/test_evaluate.py
@@ -1,6 +1,4 @@
 """Tests for the evaluation module"""
-# pylint: disable=import-error
-
 from pathlib import Path
 
 import numpy as np
@@ -90,6 +88,7 @@ def test_make_song_listener_list():
             {
                 "evaluate": {"set_random_seed": True},
                 "path": {"music_dir": None},
+                "sample_rate": 44100,
                 "nalr": {"fs": 44100},
             },
             "test",
@@ -101,14 +100,14 @@ def test_make_song_listener_list():
                 }
             },
             {
-                "left_drums": 0.149880148,
-                "right_drums": 0.143182857,
-                "left_bass": 0.140449345,
-                "right_bass": 0.181374074,
-                "left_other": 0.132401105,
-                "right_other": 0.164211137,
-                "left_vocals": 0.121260627,
-                "right_vocals": 0.126605279,
+                "left_drums": 0.140304064,
+                "right_drums": 0.127870857,
+                "left_bass": 0.149049349,
+                "right_bass": 0.132706378,
+                "left_other": 0.140922507,
+                "right_other": 0.162374351,
+                "left_vocals": 0.119106033,
+                "right_vocals": 0.123879321,
             },
         )
     ],
@@ -170,7 +169,7 @@ def test_evaluate_song_listener(
     # Combined score
     assert isinstance(combined_score, float)
     assert combined_score == pytest.approx(
-        0.144920571533222, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+        0.137026607, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
 
     # Per instrument score
diff --git a/tests/recipes/cad1/task1/test_haaqi_rms.py b/tests/recipes/cad1/task1/test_haaqi_rms.py
new file mode 100644
index 000000000..84dad02d5
--- /dev/null
+++ b/tests/recipes/cad1/task1/test_haaqi_rms.py
@@ -0,0 +1,85 @@
+"""Test for the haaqi-rms module"""
+import numpy as np
+import pytest
+
+from recipes.cad1.task1.baseline.haaqi_rms import (
+    align_signals,
+    compute_haaqi_rms,
+    find_silence_segments,
+)
+
+
+def test_align_signals():
+    """Test the function align_signals"""
+    np.random.seed(0)
+    sig_len = 600
+    reference_signal = 100 * np.random.random(size=sig_len)
+    processed_signal = reference_signal.copy()
+    processed_signal[50:] = processed_signal[:-50]
+    processed_signal[0:50] = 0
+
+    proc = align_signals(reference_signal, processed_signal)
+
+    assert proc.shape == (600,)
+    assert np.sum(np.abs(reference_signal)) == pytest.approx(
+        29892.167176853407, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert np.sum(np.abs(proc)) == pytest.approx(
+        27199.009291096496, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+
+
+def test_find_silence_segments():
+    """Test the function find_silence_segments"""
+    np.random.seed(0)
+    duration = 0.5
+    sample_rate = 8000
+    sig_len = int(duration * sample_rate)
+    reference_signal = np.concatenate(
+        (
+            100 * np.random.random(size=sig_len),
+            np.zeros(sig_len),
+            100 * np.random.random(size=sig_len),
+            np.zeros(sig_len),
+        )
+    )
+
+    silence_length = 0.1
+    silence, non_silence = find_silence_segments(
+        reference_signal, sample_rate, silence_length
+    )
+    assert silence == [[4000, 7999], [12000, 15999]]
+    assert non_silence == [[0, 3999], [8000, 11999]]
+
+
+def test_compute_haaqi_rms():
+    """Test the function compute_haaqi_rms"""
+    np.random.seed(0)
+    duration = 0.5
+    sample_rate = 1000
+    sig_len = int(duration * sample_rate)
+    reference_signal = np.concatenate(
+        (
+            100 * np.random.random(size=sig_len),
+            np.zeros(sig_len),
+            100 * np.random.random(size=sig_len),
+            np.zeros(sig_len),
+        )
+    )
+    processed_signal = reference_signal.copy()
+    processed_signal[50:] = processed_signal[:-50]
+    processed_signal[0:50] = 0
+
+    processed_signal = align_signals(reference_signal, processed_signal)
+
+    score = compute_haaqi_rms(
+        processed_signal,
+        reference_signal,
+        audiogram=np.array([45, 45, 35, 45, 60, 65]),
+        audiogram_frequencies=np.array([250, 500, 1000, 2000, 4000, 6000]),
+        sample_rate=sample_rate,
+        silence_length=0.1,
+    )
+    assert score == pytest.approx(
+        0.057594470, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/recipes/cad1/task2/test_audio_manager.py b/tests/recipes/cad1/task2/test_audio_manager.py
index 05e7537d6..db610b60a 100644
--- a/tests/recipes/cad1/task2/test_audio_manager.py
+++ b/tests/recipes/cad1/task2/test_audio_manager.py
@@ -1,5 +1,4 @@
 """Test for AudioManager module"""
-# pylint: disable=import-error
 from pathlib import Path
 
 import numpy as np
diff --git a/tests/recipes/cad1/task2/test_baseline_utils.py b/tests/recipes/cad1/task2/test_baseline_utils.py
index 2ac5220ee..1065fb610 100644
--- a/tests/recipes/cad1/task2/test_baseline_utils.py
+++ b/tests/recipes/cad1/task2/test_baseline_utils.py
@@ -1,6 +1,4 @@
 """Test for baseline_utils.py"""
-# pylint: disable=import-error
-
 from pathlib import Path
 
 import librosa
diff --git a/tests/recipes/cad1/task2/test_build_scene_metadata.py b/tests/recipes/cad1/task2/test_build_scene_metadata.py
index 578e74bba..4210df36f 100644
--- a/tests/recipes/cad1/task2/test_build_scene_metadata.py
+++ b/tests/recipes/cad1/task2/test_build_scene_metadata.py
@@ -1,6 +1,4 @@
 """Tests for the build_scene_metadata.py script"""
-# pylint: disable=import-error
-
 from pathlib import Path
 
 import numpy as np
diff --git a/tests/recipes/cad1/task2/test_enhance_task2.py b/tests/recipes/cad1/task2/test_enhance_task2.py
index 17ef5f492..718160044 100644
--- a/tests/recipes/cad1/task2/test_enhance_task2.py
+++ b/tests/recipes/cad1/task2/test_enhance_task2.py
@@ -1,6 +1,4 @@
 """Test the enhance module."""
-# pylint: disable=import-error
-
 from pathlib import Path
 
 import numpy as np
diff --git a/tests/recipes/cpc2/test_evaluate_cpc2.py b/tests/recipes/cpc2/test_evaluate_cpc2.py
index cb6414ec2..d5d2150ff 100644
--- a/tests/recipes/cpc2/test_evaluate_cpc2.py
+++ b/tests/recipes/cpc2/test_evaluate_cpc2.py
@@ -1,10 +1,12 @@
 """Tests for the CPC2 evaluation functions."""
 
 import math
+import warnings
 
 import numpy as np
 import pytest
 
+# pylint: disable=import-error, no-name-in-module
 from recipes.cpc2.baseline.evaluate import (
     compute_scores,
     kt_score,
@@ -30,7 +32,10 @@ def test_rmse_score_ok(x, y, expected):
 def test_rmse_score_error(x, y, expected):
     """Test the function rmse_score for invalid inputs"""
     with pytest.raises(expected):
-        result = rmse_score(np.array(x), np.array(y))
+        np.seterr(all="ignore")
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore")  # <--- suppress mean of empty slice warning
+            result = rmse_score(np.array(x), np.array(y))
         if np.isnan(result):
             raise ValueError
 
@@ -99,6 +104,7 @@ def test_std_err_ok(x, y, expected):
 def test_std_err_error(x, y, expected):
     """Test the function std_err for invalid inputs"""
     with pytest.raises(expected):
+        warnings.simplefilter("ignore")
         result = std_err(np.array(x), np.array(y))
         if np.isnan(result):
             raise ValueError
@@ -111,6 +117,7 @@ def test_std_err_error(x, y, expected):
 def test_compute_scores_error(x, y, expected):
     """Test the function compute_scores for invalid inputs"""
     with pytest.raises(expected):
+        warnings.simplefilter("ignore")  # <--- suppress mean of empty slice warning
         compute_scores(np.array(x), np.array(y))
 
 
diff --git a/tests/regression/_regtest_outputs/test_enhancers.test_GHA_inputs.out b/tests/regression/_regtest_outputs/test_enhancers.test_GHA_inputs.out
index da993f078..055598345 100644
--- a/tests/regression/_regtest_outputs/test_enhancers.test_GHA_inputs.out
+++ b/tests/regression/_regtest_outputs/test_enhancers.test_GHA_inputs.out
@@ -1,2 +1,11 @@
 signal output:
-[0. 0. 0. ... 0. 0. 0.]
+[[-0.30786133 -0.39199829 -0.34420776 -0.35498047]
+ [-0.30331421 -0.4085083  -0.33901978 -0.37487793]
+ [-0.29840088 -0.42907715 -0.33230591 -0.40112305]
+ [-0.29516602 -0.4453125  -0.32678223 -0.41842651]
+ [-0.29556274 -0.45758057 -0.32110596 -0.43707275]
+ [-0.29144287 -0.47451782 -0.31506348 -0.46224976]
+ [-0.28607178 -0.48565674 -0.31271362 -0.48049927]
+ [-0.28259277 -0.49224854 -0.31155396 -0.49224854]
+ [-0.28088379 -0.49679565 -0.30471802 -0.50222778]
+ [-0.28219604 -0.49954224 -0.29943848 -0.51016235]]
diff --git a/tests/regression/test_enhancers.py b/tests/regression/test_enhancers.py
index 75db2511a..e022066c3 100644
--- a/tests/regression/test_enhancers.py
+++ b/tests/regression/test_enhancers.py
@@ -63,7 +63,9 @@ def test_GHA_inputs(regtest):
 
     signal = enhancer.read_signal(merged_filename)
     np.set_printoptions(threshold=100)
-    regtest.write(f"signal output: \n{signal[:,0]}\n")
+
+    # outputting a segment not at start to avoid it being all zeros
+    regtest.write(f"signal output: \n{signal[1000:1010,:]}\n")
 
 
 def test_GHA_config(regtest):
diff --git a/tests/regression/test_predictors.py b/tests/regression/test_predictors.py
index 6a3b079e5..136377e83 100644
--- a/tests/regression/test_predictors.py
+++ b/tests/regression/test_predictors.py
@@ -1,5 +1,5 @@
 """"Regression tests for Predictors"""
-import re
+# import re
 
 import pytest
 import torch
@@ -11,10 +11,10 @@
 CPUINFO = get_cpu_info()
 
 
-@pytest.mark.skipif(
-    re.search("E5-2673", CPUINFO["brand_raw"]),
-    reason="Xeon E5-2673 CPU arch gives a different value",
-)
+# @pytest.mark.skipif(
+#    re.search("E5-2673", CPUINFO["brand_raw"]),
+#    reason="Xeon E5-2673 CPU arch gives a different value",
+# )
 def test_torch_msbg_stoi_non_xeon_e5_2673_cpu(regtest):
     torch.manual_seed(0)
     torch.set_num_threads(1)
@@ -35,10 +35,11 @@ def test_torch_msbg_stoi_non_xeon_e5_2673_cpu(regtest):
     )
 
 
-@pytest.mark.skipif(
-    not re.search("E5-2673", CPUINFO["brand_raw"]),
-    reason="Test value obtained with Xeon E5-2673",
-)
+# @pytest.mark.skipif(
+#    not re.search("E5-2673", CPUINFO["brand_raw"]),
+#    reason="Test value obtained with Xeon E5-2673",
+# )
+@pytest.mark.skip(reason="no longer needed?")
 def test_torch_msbg_stoi_xeon_e5_2673_cpu(regtest):
     torch.manual_seed(0)
     torch.set_num_threads(1)
diff --git a/tests/resources/recipes/cad1/task1/test_enhance.test_decompose_signal.npy b/tests/resources/recipes/cad1/task1/test_enhance.test_decompose_signal.npy
deleted file mode 100644
index 83f71a383..000000000
Binary files a/tests/resources/recipes/cad1/task1/test_enhance.test_decompose_signal.npy and /dev/null differ
diff --git a/tests/resources/recipes/cad1/task1/test_enhance.test_decompose_signal_demucs.npy b/tests/resources/recipes/cad1/task1/test_enhance.test_decompose_signal_demucs.npy
new file mode 100644
index 000000000..9de9a8449
Binary files /dev/null and b/tests/resources/recipes/cad1/task1/test_enhance.test_decompose_signal_demucs.npy differ
diff --git a/tests/resources/recipes/cad1/task1/test_enhance.test_decompose_signal_openunmix.npy b/tests/resources/recipes/cad1/task1/test_enhance.test_decompose_signal_openunmix.npy
new file mode 100644
index 000000000..08f436837
Binary files /dev/null and b/tests/resources/recipes/cad1/task1/test_enhance.test_decompose_signal_openunmix.npy differ
diff --git a/tests/test_compressor.py b/tests/test_compressor.py
deleted file mode 100644
index 1dbff37d7..000000000
--- a/tests/test_compressor.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import numpy as np
-
-from clarity.enhancer.compressor import Compressor
-
-DEFAULT_FS = 44100
-
-
-def test_compressor_set_attack():
-    c = Compressor()
-    c.set_attack(1000)
-
-    assert c.attack == 1.0 / DEFAULT_FS
-
-
-def test_compressor_set_release():
-    c = Compressor()
-    c.set_release(1000)
-
-    assert c.release == 1.0 / DEFAULT_FS
-
-
-def test_compressor_process():
-    c = Compressor()
-    signal = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
-    output, rms, _comp_ratios = c.process(signal)
-
-    assert len(output) == len(signal)
-    assert np.all(rms >= 0.0)
diff --git a/tests/utils/test_signal_processing.py b/tests/utils/test_signal_processing.py
index 58172b45c..e61d448ae 100644
--- a/tests/utils/test_signal_processing.py
+++ b/tests/utils/test_signal_processing.py
@@ -2,7 +2,11 @@
 import numpy as np
 import pytest
 
-from clarity.utils.signal_processing import denormalize_signals, normalize_signal
+from clarity.utils.signal_processing import (
+    compute_rms,
+    denormalize_signals,
+    normalize_signal,
+)
 
 
 @pytest.mark.parametrize(
@@ -120,3 +124,14 @@ def test_denormalize_signals(
     assert len(result) == len(expected_result)
     np.testing.assert_array_almost_equal(result[0], expected_result[0])
     np.testing.assert_array_almost_equal(result[1], expected_result[1])
+
+
+def test_compute_rms():
+    """Test the function compute_rms"""
+    np.random.seed(0)
+    sig_len = 600
+    signal = 100 * np.random.random(size=sig_len)
+    rms = compute_rms(signal)
+    assert rms == pytest.approx(
+        57.803515840, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )