Series and Parallel Filter Combinations#

Learn how to create complex filter networks by combining filters in series (sequential) and parallel (simultaneous) configurations. This tutorial demonstrates TorchFX’s intuitive operators for building sophisticated audio processing chains.

Overview#

TorchFX provides two fundamental ways to combine filters:

Configuration

Operator

Behavior

Use Case

Series

| (pipe)

Sequential processing

Filter chains, cascaded stages

Parallel

+ (addition)

Simultaneous processing with summed outputs

Band-pass filters, multi-band processing

Both can be combined to create complex signal processing networks with minimal code.

        graph TB
    subgraph "Series Configuration"
        Input1[Input] --> F1[Filter 1]
        F1 --> F2[Filter 2]
        F2 --> F3[Filter 3]
        F3 --> Output1[Output]
    end

    subgraph "Parallel Configuration"
        Input2[Input] --> Split{Split}
        Split --> FA[Filter A]
        Split --> FB[Filter B]
        Split --> FC[Filter C]
        FA --> Sum((+))
        FB --> Sum
        FC --> Sum
        Sum --> Output2[Output]
    end

    style Input1 fill:#e1f5ff
    style Output1 fill:#e1f5ff
    style Input2 fill:#e1f5ff
    style Output2 fill:#e1f5ff
    style F1 fill:#fff5e1
    style F2 fill:#fff5e1
    style F3 fill:#fff5e1
    style FA fill:#fff5e1
    style FB fill:#fff5e1
    style FC fill:#fff5e1
    

Series Filter Chains#

Series combinations use the pipeline operator (|) to process audio sequentially through multiple filters.

Basic Series Chain#

import torchfx as fx

# Load audio
wave = fx.Wave.from_file("audio.wav")

# Apply filters in series
processed = (
    wave
    | fx.filter.iir.HiButterworth(cutoff=100, order=2)    # Remove low frequencies
    | fx.filter.iir.LoButterworth(cutoff=5000, order=4)   # Remove high frequencies
    | fx.effect.Normalize(peak=0.9)                       # Normalize
)

processed.save("filtered.wav")

Signal flow: Input → High-pass (100 Hz) → Low-pass (5000 Hz) → Normalize → Output

How Series Works#

When you use the pipe operator:

  1. The Wave object flows through each filter sequentially

  2. Each filter’s forward() method processes the audio tensor

  3. A new Wave is returned at each stage

  4. Sample rate (fs) is automatically propagated through the pipeline

        sequenceDiagram
    participant Wave
    participant Filter1
    participant Filter2
    participant Filter3

    Wave->>Filter1: wave | filter1
    Note over Filter1: fs auto-configured
    Filter1->>Filter1: forward(wave.ys)
    Filter1->>Wave: Return new Wave

    Wave->>Filter2: result | filter2
    Note over Filter2: fs auto-configured
    Filter2->>Filter2: forward(result.ys)
    Filter2->>Wave: Return new Wave

    Wave->>Filter3: result | filter3
    Note over Filter3: fs auto-configured
    Filter3->>Filter3: forward(result.ys)
    Filter3->>Wave: Return final Wave
    

Multi-Stage Processing#

Build complex filter chains with multiple stages:

import torchfx as fx
from torchfx.filter import iir

wave = fx.Wave.from_file("vocal.wav")

# Multi-stage vocal processing chain
processed = (
    wave
    | iir.HiButterworth(cutoff=80, order=2)          # Remove rumble
    | iir.PeakingEQ(freq=3000, gain_db=3, q=1.0)     # Presence boost
    | iir.PeakingEQ(freq=200, gain_db=-2, q=0.7)     # Reduce muddiness
    | fx.effect.Compressor(threshold=0.5, ratio=4.0) # Dynamic control
    | fx.effect.Normalize(peak=0.95)                 # Final normalization
)

Each stage processes the output of the previous stage, allowing you to build sophisticated processing chains.

See also

Pipeline Operator - Functional Composition - Deep dive into the pipeline operator

Parallel Filter Combinations#

Parallel combinations use the addition operator (+) to apply multiple filters simultaneously and sum their outputs.

Basic Parallel Combination#

import torchfx as fx
from torchfx.filter import iir

wave = fx.Wave.from_file("audio.wav")

# Create a band-pass filter using parallel high-pass and low-pass
bandpass = iir.HiButterworth(200, order=2) + iir.LoButterworth(2000, order=4)

# Apply the combined filter
filtered = wave | bandpass

Signal flow: Input → [High-pass (200 Hz) + Low-pass (2000 Hz)] → Output

The output is the sum: result = highpass(input) + lowpass(input)

The ParallelFilterCombination Class#

When you use the + operator with filters, TorchFX creates a ParallelFilterCombination instance:

import torchfx as fx
from torchfx.filter import iir

# These are equivalent
combination1 = iir.HiButterworth(200) + iir.LoButterworth(2000)
combination2 = fx.filter.ParallelFilterCombination(
    iir.HiButterworth(200),
    iir.LoButterworth(2000)
)

The ParallelFilterCombination class:

  • Stores all filters in a filters attribute

  • Automatically propagates sample rate (fs) to all child filters

  • Sums outputs: result = sum(filter(input) for filter in filters)

        classDiagram
    class ParallelFilterCombination {
        +Sequence~AbstractFilter~ filters
        +int|None fs
        +compute_coefficients() None
        +forward(x) Tensor
    }

    class AbstractFilter {
        <<abstract>>
        +__add__(other) ParallelFilterCombination
        +compute_coefficients() None
        +forward(x) Tensor
    }

    class HiButterworth {
        +float cutoff
        +int order
        +forward(x) Tensor
    }

    class LoButterworth {
        +float cutoff
        +int order
        +forward(x) Tensor
    }

    AbstractFilter <|-- ParallelFilterCombination
    AbstractFilter <|-- HiButterworth
    AbstractFilter <|-- LoButterworth
    ParallelFilterCombination o-- AbstractFilter : contains

    note for ParallelFilterCombination "Sums outputs of all child filters"
    

How Parallel Works#

The forward() method of ParallelFilterCombination:

@torch.no_grad()
def forward(self, x: Tensor) -> Tensor:
    # Apply each filter independently
    outputs = [f.forward(x) for f in self.filters]

    # Create result tensor on same device as input
    results = torch.zeros_like(x)

    # Sum all outputs
    for output in outputs:
        results += output

    return results

This implementation:

  • Processes all filters in parallel (conceptually)

  • Uses torch.zeros_like(x) to ensure device compatibility

  • Accumulates outputs via element-wise addition

  • Disables gradients with @torch.no_grad() for efficiency

Multiple Parallel Filters#

Combine more than two filters:

import torchfx as fx
from torchfx.filter import iir

wave = fx.Wave.from_file("audio.wav")

# Three-way parallel combination
multiband = (
    iir.LoButterworth(500, order=4) +      # Low band
    iir.BandPass(500, 2000, order=2) +     # Mid band
    iir.HiButterworth(2000, order=4)       # High band
)

processed = wave | multiband

Combining Series and Parallel#

The real power comes from combining both patterns in a single pipeline.

Complete Example#

Here’s a complete example demonstrating mixed series/parallel processing:

import torchfx as fx
from torchfx.filter import iir
import torch

# Load audio
wave = fx.Wave.from_file("audio.wav")

# Move to GPU if available
device = "cuda" if torch.cuda.is_available() else "cpu"
wave = wave.to(device)

# Complex processing chain
processed = (
    wave
    # Stage 1: Remove low-frequency rumble (series)
    | iir.LoButterworth(cutoff=100, order=2)

    # Stage 2: Parallel high-pass filters (parallel)
    | iir.HiButterworth(2000, order=4) + iir.HiChebyshev1(2000, order=2)

    # Stage 3: Reduce level (series)
    | fx.effect.Gain(gain=0.5, gain_type="amplitude")
)

# Save result (move to CPU for I/O)
processed.to("cpu").save("processed.wav")

Signal Flow Visualization#

        graph TB
    Input[Input Signal<br/>from_file] --> Stage1[Stage 1: LoButterworth<br/>fc=100 Hz, order=2]

    Stage1 --> Split{Split}

    subgraph Stage2 [Stage 2: Parallel Combination]
        Split --> HP1[HiButterworth<br/>fc=2000 Hz, order=4]
        Split --> HP2[HiChebyshev1<br/>fc=2000 Hz, order=2]
        HP1 --> Sum((+))
        HP2 --> Sum
    end

    Sum --> Stage3[Stage 3: Gain<br/>amplitude=0.5]

    Stage3 --> Output[Output Signal<br/>save to file]

    style Input fill:#e1f5ff
    style Output fill:#e1f5ff
    style Stage1 fill:#fff5e1
    style HP1 fill:#fff5e1
    style HP2 fill:#fff5e1
    style Stage3 fill:#fff5e1
    style Stage2 fill:#f9f9f9
    

Operator Precedence#

Python’s operator precedence affects how series and parallel combine:

# Addition (+) has HIGHER precedence than pipe (|)
result = wave | filter1 + filter2 | filter3

# This is parsed as:
result = wave | (filter1 + filter2) | filter3

# For clarity, use explicit parentheses:
result = wave | (filter1 + filter2) | filter3

# Or break into variables:
parallel_section = filter1 + filter2
result = wave | parallel_section | filter3

Best practice: Use parentheses or intermediate variables for complex combinations.

Sample Rate Management#

Both series and parallel combinations handle sample rate automatically.

Automatic Propagation#

import torchfx as fx
from torchfx.filter import iir

# Load audio (fs automatically extracted from file)
wave = fx.Wave.from_file("audio.wav")  # fs = 44100

# Filters auto-configured with fs=44100
processed = wave | (
    iir.LoButterworth(100) + iir.HiButterworth(5000)
)

When you use the pipeline operator:

  1. Wave sets each filter’s fs attribute

  2. For ParallelFilterCombination, fs propagates to all child filters

  3. Filters compute coefficients using the correct sample rate

Manual Configuration#

You can also set sample rate explicitly:

from torchfx.filter import iir, ParallelFilterCombination

# Create parallel combination with explicit fs
parallel = ParallelFilterCombination(
    iir.HiButterworth(2000, order=2),
    iir.HiChebyshev1(2000, order=2),
    fs=44100
)

# All child filters now have fs=44100

The fs property automatically propagates to child filters that have fs=None.

Performance Considerations#

GPU Acceleration#

Both series and parallel work seamlessly on GPU:

import torchfx as fx
import torch

wave = fx.Wave.from_file("audio.wav")

# Move to GPU
if torch.cuda.is_available():
    wave = wave.to("cuda")

    # All processing happens on GPU
    processed = wave | (filter1 + filter2) | filter3

    # Move back to CPU for saving
    processed.to("cpu").save("output.wav")

The device is automatically propagated because:

See also

GPU Acceleration - GPU acceleration guide

Memory Efficiency#

For parallel combinations with many filters:

# Current implementation collects all outputs
outputs = [f.forward(x) for f in self.filters]  # List of N tensors

# Then sums them
for output in outputs:
    results += output

This holds all filter outputs in memory simultaneously. For very large signals or many filters, consider processing in chunks.

Mixing with PyTorch Modules#

Since filters inherit from torch.nn.Module, they work with standard PyTorch containers:

import torch.nn as nn
import torchfx as fx
from torchfx.filter import iir

# Use nn.Sequential
effect_chain = nn.Sequential(
    iir.LoButterworth(100, order=2),
    iir.HiButterworth(5000, order=4),
    fx.effect.Normalize()
)

wave = fx.Wave.from_file("audio.wav")
processed = wave | effect_chain

You can also mix TorchFX filters with torchaudio transforms:

import torchaudio.transforms as T
from torchfx.filter import iir

processed = (
    wave
    | iir.LoButterworth(100, order=2)
    | iir.HiButterworth(2000, order=2) + iir.HiChebyshev1(2000, order=2)
    | T.Vol(0.5)  # torchaudio volume transform
    | fx.effect.Reverb()  # torchfx reverb effect
)

Real-World Examples#

Vocal Processing Chain#

import torchfx as fx
from torchfx.filter import iir

wave = fx.Wave.from_file("vocal.wav")

# Professional vocal processing
processed = (
    wave
    # Remove rumble and noise
    | iir.HiButterworth(cutoff=80, order=2)
    | iir.LoButterworth(cutoff=12000, order=4)

    # Parallel EQ boosts for presence and air
    | (
        iir.PeakingEQ(freq=3000, gain_db=3, q=1.0) +   # Presence
        iir.PeakingEQ(freq=10000, gain_db=2, q=0.7)    # Air
    )

    # Dynamics and final polish
    | fx.effect.Compressor(threshold=0.5, ratio=4.0)
    | fx.effect.Normalize(peak=0.95)
)

Multi-Band Processing#

import torchfx as fx
from torchfx.filter import iir

wave = fx.Wave.from_file("master.wav")

# Three-band processing
low = wave | iir.LoButterworth(200, order=4) | fx.effect.Gain(1.2)
mid = wave | iir.BandPass(200, 2000, order=2) | fx.effect.Compressor(0.6, 3.0)
high = wave | iir.HiButterworth(2000, order=4) | fx.effect.Gain(1.1)

# Recombine bands
multiband = fx.Wave.merge([low, mid, high], split_channels=False)

Creative Parallel Effects#

import torchfx as fx
from torchfx.filter import iir

wave = fx.Wave.from_file("guitar.wav")

# Parallel processing for thickness
processed = (
    wave
    # Three parallel delay lines
    | (
        fx.effect.Delay(delay_samples=1000, feedback=0.3) +
        fx.effect.Delay(delay_samples=1500, feedback=0.25) +
        fx.effect.Delay(delay_samples=2000, feedback=0.2)
    )
    # Final processing
    | fx.effect.Normalize(peak=0.9)
)

Best Practices#

Use Multi-Line for Readability#

# ✅ GOOD: Clear, readable
processed = (
    wave
    | filter1
    | filter2 + filter3
    | filter4
)

# ❌ BAD: Hard to read
processed = wave | filter1 | filter2 + filter3 | filter4

Name Complex Sections#

# ✅ GOOD: Named sections
parallel_hp = iir.HiButterworth(2000, order=4) + iir.HiChebyshev1(2000, order=2)
processed = wave | iir.LoButterworth(100) | parallel_hp | fx.effect.Gain(0.5)

# ❌ LESS GOOD: Inline everything
processed = wave | iir.LoButterworth(100) | iir.HiButterworth(2000, order=4) + iir.HiChebyshev1(2000, order=2) | fx.effect.Gain(0.5)

Use Parentheses for Clarity#

# ✅ GOOD: Explicit grouping
result = wave | (lowpass + highpass) | gain

# ⚠️  WORKS: Relies on operator precedence
result = wave | lowpass + highpass | gain

Reuse Filter Combinations#

# ✅ GOOD: Reusable filter
mastering_chain = (
    iir.HiButterworth(30, order=2) +
    iir.LoButterworth(18000, order=4)
)

# Apply to multiple files
for file in audio_files:
    wave = fx.Wave.from_file(file)
    processed = wave | mastering_chain | fx.effect.Normalize()
    processed.save(f"mastered_{file}")

Common Pitfalls#

Forgetting Device Management#

# ❌ WRONG: Mixing CPU and GPU
wave_gpu = fx.Wave.from_file("audio.wav").to("cuda")
processed = wave_gpu | filter_chain
processed.save("output.wav")  # Error: can't save CUDA tensor

# ✅ CORRECT: Move to CPU before saving
processed.to("cpu").save("output.wav")

Misunderstanding Parallel Summation#

# Parallel combination SUMS outputs, not average
parallel = filter1 + filter2

# Output amplitude may be higher than input
# Consider adding gain reduction if needed
parallel_with_gain = (filter1 + filter2) | fx.effect.Gain(0.5)

Sample Rate Mismatch#

# ❌ WRONG: Manually created filters without fs
filter1 = iir.LoButterworth(100)
filter1.fs = 44100
filter2 = iir.HiButterworth(5000)
# filter2.fs is None!

parallel = ParallelFilterCombination(filter1, filter2)

# ✅ CORRECT: Let Wave set fs automatically
parallel = iir.LoButterworth(100) + iir.HiButterworth(5000)
wave = fx.Wave.from_file("audio.wav")  # fs auto-configured
processed = wave | parallel

External Resources#

References#