Exception handling across functions
Your code is buggy due to you guarding against Exception, rather than a more specific exception.
This can lead to you saying a division by zero error is actually due to the user not passing in a number.

From this you have the following choices:


Change the errors to your own errors, whilst reducing the amount of errors you guard against in your try-excepts.

This would make filter_build look more like:

def filter_build(frequency, sample_rate, filter_type, filter_order):
    nyq = 0.5 * sample_rate

    if filter_type == 'bandpass':
        try:
            normal_cutoff = (frequency[0] / nyq, frequency[1] / nyq)
        except TypeError:
            raise ValueError("Must provide tuple of frequency ranges for a bandpass filter")
    else:
        try:
            normal_cutoff = frequency / nyq
        except TypeError:
            raise ValueError("Must provide a single frequency value for this filter type")

    return butter(filter_order, normal_cutoff, btype=filter_type, analog=False)


This keeps the entire error stack, and makes debugging the code much easier, whilst keeping your error messages.
But, you may want to change your approach, as you say you expect a tuple, rather than any indexable type.
And so you could use the LBYL approach of:

def filter_build(frequency, sample_rate, filter_type, filter_order):
    nyq = 0.5 * sample_rate

    if filter_type == 'bandpass':
        if not isinstance(frequency, tuple):
            raise ValueError("Must provide tuple of frequency ranges for a bandpass filter")
        normal_cutoff = (frequency[0] / nyq, frequency[1] / nyq)
    else:
        if not isinstance(frequency, (float, int)):
            raise ValueError("Must provide a single frequency value for this filter type")
        normal_cutoff = frequency / nyq

    return butter(filter_order, normal_cutoff, btype=filter_type, analog=False)


However, you could always remove all the try-excepts, and just leave the default Python error messages.
This can look like:

def filter_build(frequency, sample_rate, filter_type, filter_order):
    nyq = 0.5 * sample_rate
    if filter_type == 'bandpass':
        normal_cutoff = (frequency[0] / nyq, frequency[1] / nyq)
    else:
        normal_cutoff = frequency / nyq
    return butter(filter_order, normal_cutoff, btype=filter_type, analog=False)


I personally don't raise custom errors in most of my code, and so use the last option mostly.
I think Python's errors are pretty good, and I'm kinda lazy.
But all three options above are good.
You need to decide how you'll handle your errors.
You want to either do no handling, and your program just exits with the error.
Or you want to handle the error and display the actual error.
To do the latter should instead look like:

import traceback

def filter(signal, frequency, sample_rate, filter_type, filter_order=2):
    try:
        b, a = filter_build(frequency, sample_rate, filter_type, filter_order)
    except Exception:
        traceback.print_exc()
        return None
    return filtfilt(b, a, signal)


I'd highly recommend that you don't do the above.
Maybe you have a usecase where it's good.
But in almost all cases, it's better to remove the try-except, and for your program to exit.




If I were writing the above two functions I'd use the following:

def filter_build(frequency, sample_rate, filter_type, filter_order):
    nyq = 0.5 * sample_rate
    if filter_type == 'bandpass':
        normal_cutoff = (frequency[0] / nyq, frequency[1] / nyq)
    else:
        normal_cutoff = frequency / nyq
    return butter(filter_order, normal_cutoff, btype=filter_type, analog=False)

def filter(signal, frequency, sample_rate, filter_type, filter_order=2):
    b, a = filter_build(frequency, sample_rate, filter_type, filter_order)
    return filtfilt(b, a, signal)