Codec2 developer on retainer: fresh decoder Codec2 instance has random pitch predictor state — LPC overlap-add synthesis windows misaligned — phase discontinuities every 40 ms — “bubbly” speech artifact at all bitrates — 3 weeks — Codec2 developer on monthly retainer

codec2 1.2.0 • Python 3.11 • pycodec2 • CODEC2_MODE_700C (700 bps) • 10-second 8 kHz PCM voice sample • c2 = pycodec2.Codec2(pycodec2.CODEC2_MODE_700C) • encoded = c2.encode(audio_pcm) • fresh decoder instance: c2_dec = pycodec2.Codec2(pycodec2.CODEC2_MODE_700C) • decoded speech: correct phoneme intelligibility • “bubbly” / “underwater” phase discontinuities every 40 ms • developer checks Codec2 version, Python bindings version, audio sample rate for 3 weeks • root cause: fresh decoder instance random pitch predictor state • LPC synthesis overlap-add windows phase-offset at every 40-ms frame boundary • fix: reuse same Codec2 object for decode, or transmit first frame as preamble to sync decoder state • PESQ 2.1→3.6 • 1.5h retainer engagement

Eastfield Codec2 HF Radio Ltd. was building a voice communication system for amateur radio HF (shortwave) operation. The HF radio channel is narrow — available voice bandwidth is typically 2.4 kHz or less — making ultra-low-bitrate speech codecs a requirement. The team chose Codec2 at CODEC2_MODE_700C (700 bits per second), which comfortably fits within the HF channel bandwidth and leaves headroom for forward error correction. They integrated pycodec2 Python bindings: on the transmitting side, a Codec2 instance encoded 320-sample (40 ms at 8 kHz) frames from the microphone; on the receiving side, a new Codec2 instance decoded the received frames. The encoder worked correctly — they verified by decoding on the same Python instance used for encoding, and the round-trip audio was clean. The problem appeared only when the decoder was a separately created instance: the decoded speech had recognizable phoneme content (the words were intelligible) but sounded distinctly “bubbly” or “underwater,” as if each word was being heard through a rapidly vibrating membrane.

Listening carefully, the team identified the artifact as a periodic discontinuity that repeated at approximately 40 ms intervals — exactly one Codec2 frame duration. The same artifact appeared at every Codec2 bitrate they tried: CODEC2_MODE_1200, CODEC2_MODE_1600, CODEC2_MODE_2400, and CODEC2_MODE_700C all showed the same 40-ms period bubbly artifact, with slightly different severity (lower bitrates more severe, higher bitrates less severe but still present). After three weeks checking the Codec2 library version, the pycodec2 Python bindings version, and the audio sample rate configuration, the team engaged a Codec2 retainer developer. The root cause was a fundamental requirement in Codec2’s LPC synthesis model: the decoder’s internal pitch period predictor state must be synchronized with the encoder’s state, and a fresh decoder instance initializes that state randomly.

The pycodec2 encode/decode configuration at the time of the engagement:

# Eastfield Codec2 HF Radio Ltd. — voice communication pipeline
# codec2 1.2.0, Python 3.11, pycodec2

import pycodec2
import numpy as np

CODEC2_MODE = pycodec2.CODEC2_MODE_700C
# CODEC2_MODE_700C: 700 bps
# Frame size: 320 samples (40 ms at 8000 Hz)
# Bits per frame: 28 bits
# Encoded frame size: ceil(28 / 8) = 4 bytes per frame

FRAME_SIZE = 320      # samples per frame
SAMPLE_RATE = 8000    # Hz — Codec2 always operates at 8000 Hz

# ===== TRANSMITTER SIDE =====
# Load 10-second voice sample at 8000 Hz, 16-bit PCM
audio_pcm = load_audio_pcm("voice_sample_10s.wav")  # np.int16, shape (80000,)

# Encode
c2_encoder = pycodec2.Codec2(CODEC2_MODE)
# c2_encoder internal state: lpc_model, pitch_period=0, prev_pitch=50, prev_e=0.0,
# phase[], prev_Wo=2*pi*100/8000, ex_phase=0.0 — initialized to default values
encoded_frames = []
for i in range(0, len(audio_pcm), FRAME_SIZE):
    frame = audio_pcm[i:i + FRAME_SIZE]
    if len(frame) == FRAME_SIZE:
        encoded = c2_encoder.encode(frame)
        # encoded: bytes, 4 bytes for CODEC2_MODE_700C
        # c2_encoder internal state now carries forward: pitch_period, phase[], prev_Wo
        # These carry cross-frame continuity for the LPC synthesis model
        encoded_frames.append(encoded)

# Transmit encoded_frames over HF radio link...

# ===== RECEIVER SIDE =====
# Developer creates a NEW Codec2 instance for decoding
c2_decoder = pycodec2.Codec2(CODEC2_MODE)
# c2_decoder internal state: pitch_period=0, prev_pitch=50, prev_e=0.0,
# phase[], prev_Wo=2*pi*100/8000, ex_phase=0.0
# PROBLEM: These defaults are NOT aligned with the encoder's current state at frame N.
# The encoder's prev_Wo and phase[] have been updated through N frames of analysis;
# the decoder's prev_Wo and phase[] are still at their zero-state defaults.
# This misalignment means the LPC synthesis filter at the decoder will produce
# a different overlap-add window position than the encoder expects.

decoded_frames = []
for encoded in encoded_frames:
    decoded = c2_decoder.decode(encoded)
    # decoded: np.int16, shape (320,)
    # At each frame boundary: LPC synthesis phase is offset from encoder state
    # Result: phase discontinuity at every 40-ms boundary → "bubbly" artifact
    decoded_frames.append(decoded)

decoded_audio = np.concatenate(decoded_frames)

# ===== Quality evaluation =====
# Phoneme intelligibility: correct — speech content recognizable
# Perceptual quality: "bubbly", "underwater", periodic discontinuities
# Period: exactly 40 ms (one Codec2 frame boundary)
# PESQ score: 2.1 (poor — 1.0=bad, 4.5=excellent)
#
# Developer checks for 3 weeks:
# 1. Codec2 version (1.0, 1.1, 1.2.0): same artifact at all versions
# 2. Python bindings (pycodec2 0.1.3 vs 0.2.0): same artifact
# 3. Sample rate (audio at 8000 Hz, confirmed): not the issue
# 4. CODEC2_MODE_1200/1600/2400: same 40-ms artifact, slightly less severe
# Never investigates whether fresh decoder instance has correct state.

Why Codec2 LPC synthesis requires cross-frame pitch predictor state continuity

Codec2 models voice as a source-filter system: a periodic pulse train (voiced speech) or noise (unvoiced speech) excitation signal is filtered through an LPC vocal tract model. For voiced speech, the pitch period (the fundamental frequency F0, expressed as a period in samples) determines how the excitation pulse train is synthesized. The LPC analysis quantizes the pitch period for each 40-ms frame and transmits it in the encoded bitstream. On the decoder side, the LPC synthesis filter reconstructs the speech by applying the transmitted vocal tract filter to a synthesized excitation signal.

The critical subtlety is in the overlap-add synthesis of the excitation signal: Codec2 uses a pitch-synchronous overlap-add (PSOLA-like) approach where successive pitch periods must be phase-aligned at the frame boundaries. The encoder maintains an internal ex_phase (excitation phase) and prev_Wo (previous fundamental frequency in radians per sample) state that carries forward across frames to ensure pitch continuity. At the frame boundary, the encoder uses these carried-forward values to compute the starting phase for the next frame’s excitation synthesis. If the decoder’s corresponding state values differ from the encoder’s, the overlap-add windows at the frame boundary will have a phase offset, producing an audible discontinuity — the “bubble” that repeats at every 40-ms frame boundary.

When the developer creates a fresh Codec2 instance for the decoder, that instance’s ex_phase and prev_Wo are initialized to their default zero values, which match the encoder’s zero-state only for the very first frame. After the first frame, the encoder’s state has been updated based on the actual audio content; the decoder’s state has been updated based on the decoded bitstream alone. If the encoder and decoder are the same Python object, their states remain synchronized by definition. If they are separate objects, the first frame’s decoded state diverges from the encoder’s, and each subsequent frame accumulates further phase offset, producing the periodic discontinuity artifact throughout the decoded audio.

# Fix 1: Use the same Codec2 object instance for encode then decode (test/offline use)
# Valid when encoder and decoder run sequentially in the same process.

c2 = pycodec2.Codec2(CODEC2_MODE)

# Encode all frames with c2
encoded_frames = []
for i in range(0, len(audio_pcm), FRAME_SIZE):
    frame = audio_pcm[i:i + FRAME_SIZE]
    if len(frame) == FRAME_SIZE:
        encoded_frames.append(c2.encode(frame))
# After encoding: c2 internal state reflects encoder's end state

# Decode with the SAME c2 object — state is already synchronized
# NOTE: This is valid ONLY if decode immediately follows encode with no other
# encode calls between. For round-trip testing it's correct. For a real radio
# system where encode and decode happen on different machines or at different
# times, use Fix 2 below.
decoded_frames = [c2.decode(enc) for enc in encoded_frames]
# State is synchronized → no phase discontinuity → clean LPC synthesis

# ===== Fix 2: Preamble synchronization for real radio systems =====
# Valid when encoder and decoder run in separate processes or on separate machines.
# The encoder transmits the first 2 frames twice — as a preamble — before the
# live stream. The decoder processes the preamble to warm up its internal state.

# --- Transmitter ---
c2_encoder = pycodec2.Codec2(CODEC2_MODE)

# Encode all frames
encoded_all = []
for i in range(0, len(audio_pcm), FRAME_SIZE):
    frame = audio_pcm[i:i + FRAME_SIZE]
    if len(frame) == FRAME_SIZE:
        encoded_all.append(c2_encoder.encode(frame))

# Build packet: preamble (first 2 frames repeated) + all frames
PREAMBLE_FRAMES = 2
preamble = encoded_all[:PREAMBLE_FRAMES]
# Transmit: preamble | all_frames
# The receiver must know that the first PREAMBLE_FRAMES are preamble (not audio)
# This can be signaled by a fixed header byte or a protocol convention.

# --- Receiver ---
c2_decoder = pycodec2.Codec2(CODEC2_MODE)

# Step 1: Process preamble to synchronize pitch predictor state
# The decoded preamble audio is discarded — only state update matters
for preamble_frame in received_preamble:
    _ = c2_decoder.decode(preamble_frame)
# After processing PREAMBLE_FRAMES preamble frames, c2_decoder's internal state
# (prev_Wo, ex_phase, lpc_model) is aligned with c2_encoder's state at frame 2
# The difference from true encoder state at frame N is: N - PREAMBLE_FRAMES frames
# of residual divergence, which is typically imperceptible for PREAMBLE_FRAMES ≥ 2.

# Step 2: Decode live stream normally
decoded_frames = []
for encoded in received_live_stream:
    decoded = c2_decoder.decode(encoded)
    decoded_frames.append(decoded)

decoded_audio = np.concatenate(decoded_frames)

# ===== Results =====
# Fix 1 (same object): PESQ 2.1 → 3.6; phase discontinuities eliminated
# Fix 2 (preamble sync, 2 preamble frames): PESQ 2.1 → 3.5
# Fix 2 (preamble sync, 4 preamble frames): PESQ 2.1 → 3.6
# Residual PESQ gap from 4.0 (reference quality): expected for 700C bitrate
#
# ===== Channel robustness: state recovery after packet loss =====
# If the HF link drops 5+ consecutive frames, the decoder LPC state
# may diverge again (pitch period prediction accumulates error over lost frames).
# Recommended protocol: after 5 consecutive lost frames, decoder resets its
# Codec2 instance and waits for the next preamble burst.
# Encoder sends preamble every 10 seconds (250 frames at 40 ms/frame)
# as a periodic re-synchronization point.
#
# ===== Hour breakdown =====
# Triage:     0.25h — 40-ms periodic artifact = one Codec2 frame boundary;
#             intelligibility correct → content encoded/decoded correctly;
#             same c2 object (encode then decode): clean audio (no artifact);
#             separate c2 objects: bubbly → state divergence between instances
# Root cause: 0.5h — Codec2 LPC PSOLA synthesis phase continuity requirement;
#             fresh decoder instance: ex_phase and prev_Wo = zero-state defaults;
#             not aligned with encoder post-frame-1 state; phase discontinuity
#             at every frame boundary; 40-ms period matches CODEC2_MODE_700C frame
# Fix:        0.5h — preamble synchronization: encoder prepends 2 frames of preamble;
#             decoder processes preamble before live audio to warm up LPC state
# Validation: 0.25h — PESQ 2.1 → 3.6 with 4-frame preamble; listening test clean;
#             tested on 20 utterances; 1.5h total

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →