Skip to content

bleed API

maai.bleed

Speaker-bleed removal for two face-to-face lapel microphones.

When two people talk face to face with lapel mics, each mic also picks up the partner's voice. This package removes that bleed with an AEC-style approach that uses the partner's mic as the reference (2-pass linear AEC + residual suppression v2), so the near-end voice is kept even during overlapping speech.

Real-time
  • :class:BleedCanceller / :class:StreamProcessor (low level)
  • maai.input.BleedCancel wraps any MaaiInput source(s) (recommended)

Offline (recorded files): - :func:process_pair for numpy arrays, :func:process_files for WAV files

maai.bleed.realtime.engine

Real-time 2ch (two lapel mics) speaker-bleed canceller.

core processor working on block samples at 16 kHz

(bidirectional x 2-pass linear AEC + residual suppression v2)

StreamProcessor : wrapper accepting any sample rate / chunk length (resampling, HPF, blocking)

Signal flow (side A; side B is symmetric): A mic --delay m--> AEC1(ref = B mic) -> e_A1 --+ (reference of B's 2nd pass) A mic --delay 2m-> AEC2(ref = e_B1) -> AES -> A out m = margin (default 32 samples = 2 ms). The mic is delayed slightly relative to the reference so that a causal filter can model the bleed even if the two input paths differ a little in delay. Algorithmic latency: passes * margin + AES latency = 64 + (nfft - hop) + look-ahead = 64 + 384 + 0 samples = 28 ms at 16 kHz (no look-ahead by default).

BleedCanceller

Core real-time bleed canceller for two synchronized 16 kHz channels.

Keyword arguments override :data:DEFAULTS. Call :meth:process with block samples per channel; the output is delayed by :attr:latency samples.

Source code in src/maai/bleed/realtime/engine.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
class BleedCanceller:
    """Core real-time bleed canceller for two synchronized 16 kHz channels.

    Keyword arguments override :data:`DEFAULTS`. Call :meth:`process` with ``block`` samples
    per channel; the output is delayed by :attr:`latency` samples.
    """

    def __init__(self, **kw):
        unknown = set(kw) - set(DEFAULTS)
        if unknown:
            raise TypeError(f"Unknown bleed canceller parameter(s): {sorted(unknown)}")
        p = dict(DEFAULTS, **kw)
        self.p = p
        B, sr = p["block"], p["sr"]
        self.block = B
        aec_kw = dict(block=B, n_part=p["n_part"], mu_max=p["mu_max"], dtd_ratio_db=p["dtd_ratio_db"],
                      near_hold_ms=p["near_hold_ms"], sr=sr)
        self.aec = [[PBFDAFStream(**aec_kw) for _ in range(2)] for _ in range(p["passes"])]  # [pass][ch]
        self.mic_delay = [[DelayLine(p["margin"] * (i + 1)) for _ in range(2)] for i in range(p["passes"])]
        self.aes2 = None
        if p["aes"]:
            self.aes2 = ResidualSuppressorV2Pair(
                block=B, sr=sr, beta=p["aes_beta"], floor_db=p["aes_floor_db"], t60=p["aes_t60"],
                dtd_ratio_db=p["dtd_ratio_db"], near_hold_ms=p["near_hold_ms"], use_pow_path=p["aes_pow_path"],
                n_bands=p["aes_n_bands"], dd_alpha=p["aes_dd_alpha"], absent_db=p["aes_absent_db"],
                near_thr_db=p["aes_near_thr_db"], lookahead_ms=p["aes_lookahead_ms"],
                post_ms=p["aes_post_ms"], cng=p["aes_cng"], refine=p["aes_refine"], res_cap=p["aes_res_cap"])
        self.delay_est = [OnlineDelayEstimator(hop=B, sr=sr, dtd_ratio_db=p["dtd_ratio_db"]) for _ in range(2)]
        # The AES reference is aligned with the estimated delay (bleed lags the reference by margin, as
        # in the offline version). The AEC reference is not re-aligned: its long filter absorbs the delay
        # and re-aligning on the fly would shift the filter taps.
        self.max_lag = self.delay_est[0].max_lag
        self.aes_ref_delay = [VariableDelay(self.max_lag) for _ in range(2)]
        aes_lat = self.aes2.latency if self.aes2 is not None else 0
        self.latency = p["passes"] * p["margin"] + aes_lat
        self.n_blocks = 0

    def process(self, a, b):
        """a, b: 16 kHz signals of length ``block``. Returns (a_out, b_out), delayed by ``latency`` samples."""
        mics = (np.asarray(a, np.float64), np.asarray(b, np.float64))
        # Delay estimation for monitoring (ch0: B->A, ch1: A->B)
        for c in range(2):
            d = self.delay_est[c].process(mics[c], mics[1 - c])
            if d is not None:
                target = int(np.clip(round(d), 0, self.max_lag))
                if abs(target - self.aes_ref_delay[c].delay) >= 2:
                    self.aes_ref_delay[c].delay = target

        refs = (mics[1], mics[0])  # 1st-pass reference = partner's raw mic
        for it, (aecs, dls) in enumerate(zip(self.aec, self.mic_delay)):
            lin, est, mic_d = [], [], []
            for c in range(2):
                m = dls[c].process(mics[c])
                e, y = aecs[c].process(m, refs[c])
                lin.append(e)
                est.append(y)
                mic_d.append(m)
            last_refs = refs
            refs = (lin[1], lin[0])  # next-pass reference = partner's linear AEC output
        self.n_blocks += 1
        if self.aes2 is None:
            return lin[0], lin[1]
        aes_refs = [self.aes_ref_delay[c].process(last_refs[c]) for c in range(2)]
        return self.aes2.process(lin, est, mic_d, aes_refs)

    def status(self):
        """Return a dict describing the current adaptation state (for monitoring)."""
        st = dict(blocks=self.n_blocks, latency_ms=1000 * self.latency / self.p["sr"],
                  delay_b_to_a=self.delay_est[0].delay, delay_a_to_b=self.delay_est[1].delay,
                  aes_ref_delay=[d.delay for d in self.aes_ref_delay],
                  aec=[[a.stats() for a in aecs] for aecs in self.aec])
        if self.aes2 is not None:
            st["aes_gain"] = list(self.aes2.last_gain)
            st["near_presence"] = list(self.aes2.last_presence)
        warn = []
        for name, d in (("B->A", st["delay_b_to_a"]), ("A->B", st["delay_a_to_b"])):
            if d is not None and d < -self.p["margin"]:
                warn.append(f"{name} delay {d:.1f} < -margin: the delay difference between the two mic "
                            f"input paths is too large (increase margin)")
        st["warnings"] = warn
        return st

process(a, b)

a, b: 16 kHz signals of length block. Returns (a_out, b_out), delayed by latency samples.

Source code in src/maai/bleed/realtime/engine.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def process(self, a, b):
    """a, b: 16 kHz signals of length ``block``. Returns (a_out, b_out), delayed by ``latency`` samples."""
    mics = (np.asarray(a, np.float64), np.asarray(b, np.float64))
    # Delay estimation for monitoring (ch0: B->A, ch1: A->B)
    for c in range(2):
        d = self.delay_est[c].process(mics[c], mics[1 - c])
        if d is not None:
            target = int(np.clip(round(d), 0, self.max_lag))
            if abs(target - self.aes_ref_delay[c].delay) >= 2:
                self.aes_ref_delay[c].delay = target

    refs = (mics[1], mics[0])  # 1st-pass reference = partner's raw mic
    for it, (aecs, dls) in enumerate(zip(self.aec, self.mic_delay)):
        lin, est, mic_d = [], [], []
        for c in range(2):
            m = dls[c].process(mics[c])
            e, y = aecs[c].process(m, refs[c])
            lin.append(e)
            est.append(y)
            mic_d.append(m)
        last_refs = refs
        refs = (lin[1], lin[0])  # next-pass reference = partner's linear AEC output
    self.n_blocks += 1
    if self.aes2 is None:
        return lin[0], lin[1]
    aes_refs = [self.aes_ref_delay[c].process(last_refs[c]) for c in range(2)]
    return self.aes2.process(lin, est, mic_d, aes_refs)

status()

Return a dict describing the current adaptation state (for monitoring).

Source code in src/maai/bleed/realtime/engine.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def status(self):
    """Return a dict describing the current adaptation state (for monitoring)."""
    st = dict(blocks=self.n_blocks, latency_ms=1000 * self.latency / self.p["sr"],
              delay_b_to_a=self.delay_est[0].delay, delay_a_to_b=self.delay_est[1].delay,
              aes_ref_delay=[d.delay for d in self.aes_ref_delay],
              aec=[[a.stats() for a in aecs] for aecs in self.aec])
    if self.aes2 is not None:
        st["aes_gain"] = list(self.aes2.last_gain)
        st["near_presence"] = list(self.aes2.last_presence)
    warn = []
    for name, d in (("B->A", st["delay_b_to_a"]), ("A->B", st["delay_a_to_b"])):
        if d is not None and d < -self.p["margin"]:
            warn.append(f"{name} delay {d:.1f} < -margin: the delay difference between the two mic "
                        f"input paths is too large (increase margin)")
    st["warnings"] = warn
    return st

StreamProcessor

Process 2ch chunks of any sample rate and length.

push(x): x is a float array of shape (n, 2) at sr_in. Returns the processed (m, 2) at out_sr (m varies because samples are buffered up to block boundaries).

Source code in src/maai/bleed/realtime/engine.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
class StreamProcessor:
    """Process 2ch chunks of any sample rate and length.

    ``push(x)``: x is a float array of shape (n, 2) at ``sr_in``. Returns the processed (m, 2) at
    ``out_sr`` (m varies because samples are buffered up to block boundaries).
    """

    def __init__(self, sr_in, out_sr=None, **kw):
        self.engine = BleedCanceller(**kw)
        p = self.engine.p
        self.sr = p["sr"]
        self.sr_in = sr_in
        self.out_sr = out_sr or self.sr
        self.down = StreamResampler(sr_in, self.sr, channels=2)
        self.up = StreamResampler(self.sr, self.out_sr, channels=2)
        if p["hpf_hz"]:
            self.sos = butter(2, p["hpf_hz"], "highpass", fs=self.sr, output="sos")
            self.zi = np.stack([sosfilt_zi(self.sos) * 0.0] * 2, axis=-1)  # (sections, 2, ch)
        else:
            self.sos = None
        self.pending = np.zeros((0, 2))

    def push(self, x):
        x = np.asarray(x, np.float64).reshape(-1, 2)
        y = self.down.process(x)
        if self.sos is not None and len(y):
            y, self.zi = sosfilt(self.sos, y, axis=0, zi=self.zi)
        self.pending = np.concatenate([self.pending, y], 0)
        B = self.engine.block
        nb = len(self.pending) // B
        outs = []
        for i in range(nb):
            blk = self.pending[i * B:(i + 1) * B]
            oa, ob = self.engine.process(blk[:, 0], blk[:, 1])
            outs.append(np.stack([oa, ob], 1))
        self.pending = self.pending[nb * B:]
        out = np.concatenate(outs, 0) if outs else np.zeros((0, 2))
        return self.up.process(out) if len(out) else out

    def latency_ms(self):
        """Algorithmic latency incl. block buffering and resampler group delay (excl. device buffers)."""
        lat = self.engine.latency + self.engine.block  # processing latency + max wait for a full block
        ms = 1000 * lat / self.sr
        if not self.down.passthrough:
            ms += 1000 * self.down.delay_in_samples / self.sr_in
        if not self.up.passthrough:
            ms += 1000 * self.up.delay_in_samples / self.sr
        return ms

latency_ms()

Algorithmic latency incl. block buffering and resampler group delay (excl. device buffers).

Source code in src/maai/bleed/realtime/engine.py
199
200
201
202
203
204
205
206
207
def latency_ms(self):
    """Algorithmic latency incl. block buffering and resampler group delay (excl. device buffers)."""
    lat = self.engine.latency + self.engine.block  # processing latency + max wait for a full block
    ms = 1000 * lat / self.sr
    if not self.down.passthrough:
        ms += 1000 * self.down.delay_in_samples / self.sr_in
    if not self.up.passthrough:
        ms += 1000 * self.up.delay_in_samples / self.sr
    return ms

VariableDelay

Delay line whose delay can change on the fly (up to max_delay).

Source code in src/maai/bleed/realtime/engine.py
63
64
65
66
67
68
69
70
71
72
73
74
class VariableDelay:
    """Delay line whose delay can change on the fly (up to ``max_delay``)."""

    def __init__(self, max_delay):
        self.buf = np.zeros(max_delay)
        self.delay = 0

    def process(self, x):
        y = np.concatenate([self.buf, x])
        self.buf = y[len(x):]
        end = len(y) - self.delay
        return y[end - len(x):end]

maai.bleed.offline.pipeline

Offline bleed removal for one recorded session (two lapel mics).

mic A --+
        +- delay estimation -> reference alignment -> linear AEC (PBFDAF) -> residual suppression -> A cleaned
mic B --+ (reference)

Side B is processed symmetrically with A as the reference.

aes_stage(mic, res, ref_al, p, log)

Residual suppression. Returns (output, output without comfort noise).

Source code in src/maai/bleed/offline/pipeline.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def aes_stage(mic, res, ref_al, p, log):
    """Residual suppression. Returns (output, output without comfort noise)."""
    if not p["aes"]:
        return res["e"], res["e"]
    t0 = time.time()
    out, st, clean = residual_echo_suppress_v2(
        res["e"], res["y"], mic, ref_al, sr=p["sr"], beta=p["aes_beta"], floor_db=p["aes_floor_db"],
        t60=p["aes_t60"], dtd_ratio_db=p["dtd_ratio_db"], near_hold_ms=p["near_hold_ms"],
        use_pow_path=p["aes_pow_path"], dd_alpha=p["aes_dd_alpha"], absent_db=p["aes_absent_db"],
        near_thr_db=p["aes_near_thr_db"], pre_ms=p["aes_pre_ms"], post_ms=p["aes_post_ms"],
        cng=p["aes_cng"], n_bands=p["aes_n_bands"], res_cap=p["aes_res_cap"], return_clean=True)
    log["aes"] = st
    log["aes_sec"] = round(time.time() - t0, 1)
    return out, clean

linear_stage(mic, ref, delay, clock_drift, p, log)

Linear AEC. Returns (pbfdaf result dict, aligned reference).

Source code in src/maai/bleed/offline/pipeline.py
60
61
62
63
64
65
66
67
68
69
def linear_stage(mic, ref, delay, clock_drift, p, log):
    """Linear AEC. Returns (pbfdaf result dict, aligned reference)."""
    ref_al = align_reference(ref, len(mic), delay, p["margin"], clock_drift, p["drift_threshold"])
    t0 = time.time()
    res = pbfdaf(mic, ref_al, block=p["block"], n_part=p["n_part"], mu_max=p["mu_max"],
                 dtd_ratio_db=p["dtd_ratio_db"], adapt_mode=p["adapt_mode"],
                 near_hold_ms=p["near_hold_ms"], sr=p["sr"])
    log["aec"] = res["stats"]
    log["aec_sec"] = round(time.time() - t0, 1)
    return res, ref_al

process_files(s1=None, s2=None, stereo=None, out_dir='.', prefix=None, **params)

Apply offline bleed removal to recorded files and write the results.

Give either two mono files (s1, s2) or one stereo file (stereo, L=s1, R=s2). Writes {prefix}_s1.wav, {prefix}_s2.wav, {prefix}_stereo.wav (16 kHz, PCM16) and {prefix}_bleed_log.json into out_dir.

Parameters:

Name Type Description Default
s1 str

Mono WAV of speaker 1's mic.

None
s2 str

Mono WAV of speaker 2's mic.

None
stereo str

Stereo WAV (L = speaker 1, R = speaker 2).

None
out_dir str

Output directory.

'.'
prefix str

Output file prefix (defaults to the input file name).

None
**params

Overrides of :data:DEFAULTS.

{}

Returns:

Name Type Description
dict

Paths of the written files (keys: s1, s2, stereo, log).

Source code in src/maai/bleed/offline/pipeline.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def process_files(s1=None, s2=None, stereo=None, out_dir=".", prefix=None, **params):
    """Apply offline bleed removal to recorded files and write the results.

    Give either two mono files (``s1``, ``s2``) or one stereo file (``stereo``, L=s1, R=s2).
    Writes ``{prefix}_s1.wav``, ``{prefix}_s2.wav``, ``{prefix}_stereo.wav`` (16 kHz, PCM16)
    and ``{prefix}_bleed_log.json`` into ``out_dir``.

    Args:
        s1 (str, optional): Mono WAV of speaker 1's mic.
        s2 (str, optional): Mono WAV of speaker 2's mic.
        stereo (str, optional): Stereo WAV (L = speaker 1, R = speaker 2).
        out_dir (str): Output directory.
        prefix (str, optional): Output file prefix (defaults to the input file name).
        **params: Overrides of :data:`DEFAULTS`.

    Returns:
        dict: Paths of the written files (keys: s1, s2, stereo, log).
    """
    t0 = time.time()
    if stereo is not None:
        x, sr_in = sf.read(stereo, dtype="float64")
        if x.ndim != 2 or x.shape[1] < 2:
            raise ValueError(f"{stereo}: expected a file with at least 2 channels")
        a, b = x[:, 0], x[:, 1]
        src = stereo
    elif s1 is not None and s2 is not None:
        a, sr_in = _read_mono(s1)
        b, sr_b = _read_mono(s2)
        if sr_in != sr_b:
            raise ValueError(f"Sample rate mismatch: {sr_in} vs {sr_b}")
        src = s1
    else:
        raise ValueError("Specify either stereo=... or both s1=... and s2=...")
    if len(a) != len(b):
        warnings.warn(f"Length mismatch {len(a)} vs {len(b)}; truncating to the shorter one")

    a_out, b_out, log = process_pair(a, b, sr_in, **params)
    sr = log["params"]["sr"]

    prefix = prefix or os.path.splitext(os.path.basename(src))[0]
    os.makedirs(out_dir, exist_ok=True)
    paths = {k: os.path.join(out_dir, f"{prefix}_{k}.wav") for k in ("s1", "s2", "stereo")}
    paths["log"] = os.path.join(out_dir, f"{prefix}_bleed_log.json")
    ia, ib = _to_int16(a_out), _to_int16(b_out)
    sf.write(paths["s1"], ia, sr, subtype="PCM_16")
    sf.write(paths["s2"], ib, sr, subtype="PCM_16")
    sf.write(paths["stereo"], np.stack([ia, ib], 1), sr, subtype="PCM_16")
    log.update(input_s1=s1, input_s2=s2, input_stereo=stereo, input_sr=sr_in,
               clipped_samples=int(np.sum(np.abs(a_out) >= 1.0) + np.sum(np.abs(b_out) >= 1.0)),
               elapsed_sec=round(time.time() - t0, 1))
    with open(paths["log"], "w", encoding="utf-8") as f:
        json.dump(log, f, indent=1, ensure_ascii=False, default=float)
    return paths

process_pair(a, b, sr_in, **kw)

Remove the mutual bleed between two synchronized lapel-mic recordings.

Parameters:

Name Type Description Default
a ndarray

Mono signal of speaker 1's mic.

required
b ndarray

Mono signal of speaker 2's mic (same clock / start as a).

required
sr_in int

Sample rate of a and b.

required
**kw

Overrides of :data:DEFAULTS.

{}

Returns:

Name Type Description
tuple

(a_clean, b_clean, log). The outputs are float32 at sr (16 kHz by default).

Source code in src/maai/bleed/offline/pipeline.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def process_pair(a, b, sr_in, **kw):
    """Remove the mutual bleed between two synchronized lapel-mic recordings.

    Args:
        a (np.ndarray): Mono signal of speaker 1's mic.
        b (np.ndarray): Mono signal of speaker 2's mic (same clock / start as ``a``).
        sr_in (int): Sample rate of ``a`` and ``b``.
        **kw: Overrides of :data:`DEFAULTS`.

    Returns:
        tuple: (a_clean, b_clean, log). The outputs are float32 at ``sr`` (16 kHz by default).
    """
    unknown = set(kw) - set(DEFAULTS)
    if unknown:
        raise TypeError(f"Unknown bleed removal parameter(s): {sorted(unknown)}")
    p = dict(DEFAULTS, **kw)
    sr = p["sr"]
    a = preprocess(np.asarray(a, np.float64), sr_in, sr, p["hpf_hz"])
    b = preprocess(np.asarray(b, np.float64), sr_in, sr, p["hpf_hz"])
    n = min(len(a), len(b))
    a, b = a[:n], b[:n]

    # peak normalization (as in Seamless Interaction); the original scale is restored at the end
    ga = np.max(np.abs(a)) + 1e-12
    gb = np.max(np.abs(b)) + 1e-12
    a, b = a / ga, b / gb

    # Delay estimation: half the difference of the two directions' slopes = clock drift
    # (distance changes due to speaker movement have the same sign in both directions)
    dBA = estimate_delay(a, b, sr, ratio_db=p["dtd_ratio_db"])
    dAB = estimate_delay(b, a, sr, ratio_db=p["dtd_ratio_db"])
    clock_drift = (dBA["drift"] - dAB["drift"]) / 2.0
    log = dict(params=p, n_samples=n, peak_a=float(ga), peak_b=float(gb), clock_drift=clock_drift,
               delay_b_to_a={k: v for k, v in dBA.items() if k != "block_delays"},
               delay_a_to_b={k: v for k, v in dAB.items() if k != "block_delays"},
               a={}, b={})
    dl_a = (dBA["bulk_delay"], clock_drift)   # delay of the B reference relative to A's mic
    dl_b = (dAB["bulk_delay"], -clock_drift)

    # Linear AEC. From the 2nd pass on, the partner's linear AEC output is the reference (less of our
    # own voice in the reference). The non-linear AES output is not used for the linear filter.
    ref_b, ref_a = b, a
    for it in range(p["passes"]):
        la = log["a"].setdefault(f"pass{it + 1}", {})
        lb = log["b"].setdefault(f"pass{it + 1}", {})
        res_a, ral_a = linear_stage(a, ref_b, *dl_a, p, la)
        res_b, ral_b = linear_stage(b, ref_a, *dl_b, p, lb)
        ref_b, ref_a = res_b["e"].astype(np.float64), res_a["e"].astype(np.float64)

    # Residual suppression
    a_out, a_cl = aes_stage(a, res_a, ral_a, p, la)
    b_out, b_cl = aes_stage(b, res_b, ral_b, p, lb)
    # aes_refine: re-run AES using the partner's AES output (without comfort noise) as the reference.
    # Less of our own voice remains in the reference, so it no longer leaks into the power-domain bleed
    # estimate (the power model ignores phase, so a non-linearly processed reference is fine).
    for it in range(p["aes_refine"]):
        lr_a = log["a"].setdefault(f"refine{it + 1}", {})
        lr_b = log["b"].setdefault(f"refine{it + 1}", {})
        rb = align_reference(b_cl.astype(np.float64), n, dl_a[0], p["margin"], dl_a[1], p["drift_threshold"])
        ra = align_reference(a_cl.astype(np.float64), n, dl_b[0], p["margin"], dl_b[1], p["drift_threshold"])
        a_out, a_cl2 = aes_stage(a, res_a, rb, p, lr_a)
        b_out, b_cl = aes_stage(b, res_b, ra, p, lr_b)
        a_cl = a_cl2

    return (a_out * ga).astype(np.float32), (b_out * gb).astype(np.float32), log