Metadata-Version: 2.4
Name: blip25-vocoder
Version: 1.0.0
Classifier: Development Status :: 5 - Production/Stable
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Classifier: Topic :: Multimedia :: Sound/Audio
Classifier: Topic :: Communications :: Ham Radio
Requires-Dist: numpy>=1.22
Requires-Dist: pytest>=7 ; extra == 'test'
Provides-Extra: test
License-File: LICENSE
Summary: Python bindings for blip25-vocoder — the P25 voice codec, full rate and half rate
Author: Chance Lindsey
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Homepage, https://github.com/openBLIP25/blip25-vocoder-py
Project-URL: Repository, https://github.com/openBLIP25/blip25-vocoder-py
Project-URL: Upstream, https://github.com/openBLIP25/blip25-vocoder

# blip25-vocoder (Python)

Python bindings for [`blip25-vocoder`](https://github.com/openBLIP25/blip25-vocoder) —
the P25 voice codec, bit-exact with the reference vocoder.

Full rate (Phase 1 FDMA) and half rate (Phase 2 TDMA), four wire formats,
soft-decision FEC, concealment, and rate conversion. PCM crosses the boundary
as 1-D `numpy.int16`; wire frames cross as `bytes`.

> **Patent notice.** This package is provided for research and interoperability
> study only. The half-rate implementation unavoidably reads on the claims of
> **US8359197**, active until **2028-05-20**, and the codec core is
> reverse-engineered from a compiled image of the reference vocoder. Read
> [`PATENT_NOTICE.md`](./PATENT_NOTICE.md) before use.

> **Trademarks.** IMBE, AMBE, and AMBE+2 are trademarks of Digital Voice
> Systems, Inc.; NXDN and IDAS are trademarks of Icom Incorporated (NXDN
> jointly with JVC KENWOOD). This project is not affiliated with or endorsed by
> any of them.

## Install

```bash
pip install blip25-vocoder
```

Requires Python 3.9+ and numpy 1.22+. Wheels are `abi3`, so one wheel per
platform covers every supported Python.

## Quick start

```python
import numpy as np
from blip25_vocoder import Rate, Vocoder

tx = Vocoder(Rate.FULL_RATE_7200X4400)
rx = Vocoder(Rate.FULL_RATE_7200X4400)

pcm = np.zeros(160, dtype=np.int16)     # 20 ms of 8 kHz mono
frame = tx.encode_pcm(pcm)              # -> bytes, 18 long
out = rx.decode_bits(frame)             # -> np.int16, 160 long
```

One frame is always **160 `int16` samples — 20 ms of 8 kHz mono**, at every
rate (`FRAME_SAMPLES`). What changes with the rate is the wire frame size.

**The encoder carries one frame of look-ahead.** The bytes returned by
`encode_pcm` describe the *previous* frame's audio. For anything longer than a
one-shot, drive the stream through `LiveEncoder` rather than reasoning about
that offset yourself.

## The four rates

```python
rate = Rate.HALF_RATE_3600X2450
rate.fec_frame_bytes    # 9
rate.frame_samples      # 160
rate.soft_frame_bits    # 72, or None on the info-only rates
```

| `Rate` | Wire frame | Soft bits | What it is for |
|---|---:|---:|---|
| `FULL_RATE_7200X4400` | 18 bytes | 144 | P25 Phase 1 FDMA, on air, FEC included |
| `FULL_RATE_4400X4400` | 11 bytes | — | Full-rate payload, FEC stripped |
| `HALF_RATE_3600X2450` | 9 bytes | 72 | P25 Phase 2 TDMA, on air, Annex-S interleave included |
| `HALF_RATE_2450X2450` | 7 bytes | — | Half-rate payload in **natural / AMBE_d order** — what P25 encrypts, and what mbelib, DSD, MMDVM and an IDAS/NXDN wire use |

The two info-only rates carry no FEC layer, so `soft_frame_bits` is `None` and
`decode_soft` raises `ValueError` on them.

## Streaming

`LiveEncoder` and `LiveDecoder` take whatever chunk size you have and buffer
the remainder. This is what you want behind an audio callback or a socket.

```python
from blip25_vocoder import LiveEncoder, LiveDecoder, Rate

enc = LiveEncoder(Rate.HALF_RATE_3600X2450)
dec = LiveDecoder(Rate.HALF_RATE_3600X2450)

for chunk in audio_source():                # any length, np.int16
    for frame in enc.push(chunk):           # 0..n frames per push
        for pcm in dec.push(frame):
            play(pcm)

for frame in enc.flush():                   # drains residue + look-ahead
    for pcm in dec.push(frame):
        play(pcm)

enc.pending_samples   # buffered, not yet a whole frame
dec.pending_bytes
```

`flush()` pads the pending residue with zeros and drains the look-ahead,
returning 0, 1, or 2 frames. Call it once at end of stream.

## Lost frames and concealment

Do **not** simply skip a frame the transport lost — that desynchronises the
decoder from the sender. Feed an erasure instead:

```python
pcm = rx.decode_bits(rx.erasure_frame)      # hold + fade
```

The decoder holds the last good frame and fades it out over successive
erasures. Both rates mark an erasure in-band by putting the pitch index outside
its valid range, so this works on the info-only rates too.

To know what actually happened to a frame:

```python
d = rx.last_disposition()
if d.concealed:
    ...     # audio returned is NOT what the sender encoded
if d.tone:
    ...     # in-band signalling tone, not voice

rx.last_decode_errors()     # (epsilon_0, epsilon_t) FEC corrections
```

`Disposition` is a **bit word, not a classification** — a tone frame reports
tone *and* speech. Read the individual flags (`speech`, `concealed`,
`activity`, `tone`, `raw`); do not compare the whole value. Note that `speech`
is set on essentially every frame of real audio on both sides — it is not a
voiced/unvoiced discriminator.

`last_decode_errors()` returns `(0, 0)` on the info-only rates, which carry no
parity to count. Rising values are the channel degrading before concealment
engages — a useful signal to surface in a receiver UI.

## When your own FEC layer is upstream

If something ahead of this package already ran FEC and knows how damaged the
frame was, hand that in — the codec conceals with it, and it is not advisory:

```python
from blip25_vocoder import FrameStatus

pcm = rx.decode_bits_with_status(frame, FrameStatus.clean())
pcm = rx.decode_bits_with_status(frame, FrameStatus.lost())    # hold + fade
pcm = rx.decode_bits_with_status(frame, FrameStatus(err_count))
```

`FrameStatus(n)` clamps a raw error metric into the codec's live 0–15 band for
you. Passing an unclamped 255 straight through would silently mean *no
concealment* on the worst frame in the stream. Pass `mute=True` to drop to
silence within one frame instead of fading.

## Encrypted voice: the info-vector seam

P25 encrypts the vocoder payload, not the FEC-bearing frame — the keystream XOR
sits between the vocoder and the burst. So an encrypted link stops before the
wire layer in both directions:

```python
info = tx.encode_info(pcm)                      # -> list[int] vectors
# ... your encryption, your FEC, your framing ...
pcm = rx.decode_info(info, FrameStatus.clean())
```

Call `decode_info` even for a slot with nothing usable in it — pass
`FrameStatus.lost()` — so the codec stays in step with the sender.

To go the other way from an already-framed stream,
`fullrate.fec_to_info_bytes(frame18)` strips the FEC layer and returns the
11-byte payload encryption operates on.

## Soft-decision decode

If your demodulator can surface per-bit confidence, feed LLRs instead of hard
bits. Sign is the hard decision, magnitude is confidence. Worth roughly 2 dB.

```python
llrs = np.array([...], dtype=np.int8)       # len == rx.soft_frame_bits
pcm = rx.decode_soft(llrs)
```

## Rate conversion

```python
from blip25_vocoder import Transcoder, Rate

t = Transcoder(Rate.FULL_RATE_7200X4400, Rate.HALF_RATE_3600X2450)
half_frame = t.transcode(full_frame)
```

Same-rate FEC ⇄ no-FEC hops are exact. Cross-rate conversion is close in audio
terms but **not** bit-exact with the reference converter — see the upstream
CHANGELOG's known limitations before relying on it for interop.

## Ending a stream

`LiveEncoder.flush()` handles this for you. Driving `Vocoder.encode_pcm`
frame-by-frame instead, call `flush_encode()` at the end to drain the encoder's
one-frame look-ahead — otherwise the last frame of audio never comes out:

```python
tail = tx.flush_encode()        # list[bytes], possibly empty
```

## Non-P25 carriers: the halfrate toolkit

The half-rate frame is not P25-specific. DMR Tier II/III and NXDN carry the
same 49→72-bit codec frame — same Golay pair, same PN scramble, same bit
prioritization — and differ only in the interleave above it. Enter one layer
down, supplying your own interleave:

```python
from blip25_vocoder import halfrate

b = halfrate.fields_from_fec(frame9)        # 9 voice-parameter fields
b = halfrate.fields_from_natural(frame7)    # from natural / AMBE_d bytes

u = halfrate.prioritize(b)                  # 9 fields -> 4 info vectors
c = halfrate.encode_code_vectors(u)         # -> 4 code vectors, no interleave
```

Constants for laying out your own framing: `INFO_WIDTHS`, `CODE_WIDTHS`,
`INFO_BITS_TOTAL`, `DIBITS_PER_FRAME`, `SOFT_BITS`, `PARAM_COUNT`,
`PARAM_WIDTHS`, `VECTOR_WIDTHS`.

D-STAR uses first-generation AMBE rather than the half-rate codec here and is
not supported.

`fullrate` is the Phase 1 peer of the same layer — eight info vectors instead of
four, 72 dibits instead of 36:

```python
from blip25_vocoder import fullrate

payload11 = fullrate.fec_to_info_bytes(frame18)   # strip the FEC layer
dibits = fullrate.encode_frame(info8)             # 8 vectors -> 72 dibits
```

## What this package does not wrap

Two things in the Rust crate have no Python equivalent, deliberately:

- **The iterator streams** (`encode_stream` / `decode_stream`) — `LiveEncoder`
  and `LiveDecoder` cover the same ground and fit Python better.
- **`VocoderBuilder`** — the one thing it configures, enhancement, is a plain
  setter here.

## Types at the boundary

| Direction | Type |
|---|---|
| PCM in (`encode_pcm`, `LiveEncoder.push`) | 1-D `np.int16`, C-contiguous |
| PCM out (`decode_bits`, `decode_soft`, `LiveDecoder.push`) | 1-D `np.int16` |
| Wire frames, both directions | `bytes` |
| LLRs (`decode_soft`) | 1-D `np.int8` |

A wrong length or dtype raises `ValueError` with the upstream message. Pass a
contiguous array — slice with `np.ascontiguousarray` if yours is strided.

## State

A `Vocoder` is one channel in one direction and is stateful across frames
(analysis history, decoder cross-frame memory). Use a separate instance per
direction, and call `reset()` between independent streams:

```python
v.reset()
```

The objects are not thread-safe and are marked `unsendable` — use one per
thread.

## Enhancement

```python
from blip25_vocoder import EnhancementMode
v.set_enhancement(EnhancementMode.CLASSICAL)
```

`NONE` is the default and is the reference decode path unaltered. `CLASSICAL`
applies a biquad + peaking-EQ + output-gain post-filter — a deliberate
deviation from the reference. Leave it off if you are comparing against other
implementations.

## License

MIT for this binding layer. The codec it wraps is patent-encumbered — see
[`PATENT_NOTICE.md`](./PATENT_NOTICE.md).

