desphere 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- desphere/__init__.py +49 -0
- desphere/cli.py +155 -0
- desphere/codecs.py +217 -0
- desphere/errors.py +34 -0
- desphere/g711.py +49 -0
- desphere/shorten.py +339 -0
- desphere/sphere.py +238 -0
- desphere/transcode.py +97 -0
- desphere/wav.py +73 -0
- desphere-0.1.0.dist-info/METADATA +180 -0
- desphere-0.1.0.dist-info/RECORD +14 -0
- desphere-0.1.0.dist-info/WHEEL +4 -0
- desphere-0.1.0.dist-info/entry_points.txt +2 -0
- desphere-0.1.0.dist-info/licenses/LICENSE +21 -0
desphere/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""desphere — a clean-room NIST SPHERE -> RIFF/WAV transcoder.
|
|
2
|
+
|
|
3
|
+
Flatten a "sphere" (NIST SPHERE audio) into a flat WAV. MIT-licensed,
|
|
4
|
+
zero-dependency, built only from public format documentation and black-box
|
|
5
|
+
testing — never from GPL/LGPL source.
|
|
6
|
+
|
|
7
|
+
Public API::
|
|
8
|
+
|
|
9
|
+
from desphere import read_sphere, transcode, sph_to_wav, SphereHeader
|
|
10
|
+
|
|
11
|
+
header, data = read_sphere("utt.sph")
|
|
12
|
+
with open("utt.wav", "wb") as f:
|
|
13
|
+
transcode(header, data, f)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .errors import (
|
|
19
|
+
DesphereError,
|
|
20
|
+
SphereHeaderError,
|
|
21
|
+
UnsupportedCoding,
|
|
22
|
+
UnsupportedFormat,
|
|
23
|
+
)
|
|
24
|
+
from .sphere import SphereHeader
|
|
25
|
+
from .transcode import (
|
|
26
|
+
native_available,
|
|
27
|
+
read_sphere,
|
|
28
|
+
sph_to_wav,
|
|
29
|
+
transcode,
|
|
30
|
+
transcode_bytes,
|
|
31
|
+
)
|
|
32
|
+
from .wav import write_wav
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"SphereHeader",
|
|
39
|
+
"read_sphere",
|
|
40
|
+
"transcode",
|
|
41
|
+
"transcode_bytes",
|
|
42
|
+
"sph_to_wav",
|
|
43
|
+
"native_available",
|
|
44
|
+
"write_wav",
|
|
45
|
+
"DesphereError",
|
|
46
|
+
"SphereHeaderError",
|
|
47
|
+
"UnsupportedCoding",
|
|
48
|
+
"UnsupportedFormat",
|
|
49
|
+
]
|
desphere/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""``sph2wav`` command-line interface.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
sph2wav INPUT.sph [OUTPUT.wav]
|
|
6
|
+
sph2wav --info INPUT.sph
|
|
7
|
+
sph2wav INPUT.sph - # write WAV to stdout
|
|
8
|
+
|
|
9
|
+
Mirrors the name of the tool people migrate away from, so it is easy to find.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .errors import DesphereError
|
|
21
|
+
from .sphere import SphereHeader
|
|
22
|
+
from .transcode import transcode_bytes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_output(in_path: str) -> str:
|
|
26
|
+
root, _ = os.path.splitext(in_path)
|
|
27
|
+
return root + ".wav"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _print_info(header: SphereHeader, in_path: str, data_len: int) -> None:
|
|
31
|
+
duration = (
|
|
32
|
+
header.sample_count / header.sample_rate if header.sample_rate else 0.0
|
|
33
|
+
)
|
|
34
|
+
print(f"{in_path}")
|
|
35
|
+
print(f" sample_coding : {header.sample_coding}")
|
|
36
|
+
print(f" sample_rate : {header.sample_rate} Hz")
|
|
37
|
+
print(f" channel_count : {header.channel_count}")
|
|
38
|
+
print(f" sample_n_bytes : {header.sample_n_bytes} ({header.sample_n_bytes * 8}-bit)")
|
|
39
|
+
print(f" sample_byte_format : {header.sample_byte_format}")
|
|
40
|
+
print(f" sample_sig_bits : {header.sample_sig_bits}")
|
|
41
|
+
print(f" sample_count : {header.sample_count} ({duration:.3f} s)")
|
|
42
|
+
print(f" header_size : {header.header_size} bytes")
|
|
43
|
+
tokens = [t.strip().lower() for t in header.sample_coding.split(",")]
|
|
44
|
+
if len(tokens) > 1 and tokens[1]:
|
|
45
|
+
# Compressed: the payload is a bitstream, so expected_data_bytes is the
|
|
46
|
+
# UNCOMPRESSED size, not a target to match.
|
|
47
|
+
print(
|
|
48
|
+
f" payload bytes : {data_len} "
|
|
49
|
+
f"(compressed; uncompressed ~{header.expected_data_bytes})"
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
print(f" payload bytes : {data_len} (expected {header.expected_data_bytes})")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
56
|
+
p = argparse.ArgumentParser(
|
|
57
|
+
prog="sph2wav",
|
|
58
|
+
description="Transcode a NIST SPHERE file to RIFF/WAV (desphere).",
|
|
59
|
+
)
|
|
60
|
+
p.add_argument("input", help="input .sph file")
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"output",
|
|
63
|
+
nargs="?",
|
|
64
|
+
help="output .wav file (default: input with .wav extension; '-' for stdout)",
|
|
65
|
+
)
|
|
66
|
+
p.add_argument("--info", action="store_true", help="print header and exit")
|
|
67
|
+
p.add_argument(
|
|
68
|
+
"-f", "--force", action="store_true", help="overwrite output if it exists"
|
|
69
|
+
)
|
|
70
|
+
p.add_argument("--version", action="version", version=f"desphere {__version__}")
|
|
71
|
+
return p
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_wav(b: bytes) -> bool:
|
|
75
|
+
return len(b) >= 12 and b[:4] == b"RIFF" and b[8:12] == b"WAVE"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv=None) -> int:
|
|
79
|
+
args = build_parser().parse_args(argv)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
with open(args.input, "rb") as f:
|
|
83
|
+
raw = f.read()
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
print(f"sph2wav: cannot read {args.input}: {exc}", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
|
|
88
|
+
if args.info:
|
|
89
|
+
if _is_wav(raw):
|
|
90
|
+
print(f"{args.input}\n (already a RIFF/WAV file, not NIST SPHERE)")
|
|
91
|
+
return 0
|
|
92
|
+
try:
|
|
93
|
+
header = SphereHeader.parse(raw)
|
|
94
|
+
except DesphereError as exc:
|
|
95
|
+
print(f"sph2wav: {exc}", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
_print_info(header, args.input, len(raw) - header.header_size)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
out_path = args.output or _default_output(args.input)
|
|
101
|
+
to_stdout = out_path == "-"
|
|
102
|
+
|
|
103
|
+
if not to_stdout:
|
|
104
|
+
if os.path.abspath(out_path) == os.path.abspath(args.input):
|
|
105
|
+
print(
|
|
106
|
+
"sph2wav: refusing to overwrite the input file; "
|
|
107
|
+
"specify a different output path",
|
|
108
|
+
file=sys.stderr,
|
|
109
|
+
)
|
|
110
|
+
return 2
|
|
111
|
+
if os.path.exists(out_path) and not args.force:
|
|
112
|
+
print(
|
|
113
|
+
f"sph2wav: {out_path} exists (use -f/--force to overwrite)",
|
|
114
|
+
file=sys.stderr,
|
|
115
|
+
)
|
|
116
|
+
return 2
|
|
117
|
+
|
|
118
|
+
if _is_wav(raw):
|
|
119
|
+
# A stray WAV is already what the caller wants — pass it through, warning
|
|
120
|
+
# so the mistake is visible. (The library stays strict; this is CLI UX.)
|
|
121
|
+
print(
|
|
122
|
+
f"sph2wav: {args.input} is already a RIFF/WAV file; "
|
|
123
|
+
"copying it through unchanged",
|
|
124
|
+
file=sys.stderr,
|
|
125
|
+
)
|
|
126
|
+
wav = raw
|
|
127
|
+
else:
|
|
128
|
+
try:
|
|
129
|
+
wav = transcode_bytes(raw) # uses the Rust accelerator if installed
|
|
130
|
+
except DesphereError as exc:
|
|
131
|
+
print(f"sph2wav: {exc}", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
|
|
134
|
+
if to_stdout:
|
|
135
|
+
sys.stdout.buffer.write(wav)
|
|
136
|
+
else:
|
|
137
|
+
# Atomic write: a temp file renamed on success leaves no partial output.
|
|
138
|
+
out_dir = os.path.dirname(os.path.abspath(out_path)) or "."
|
|
139
|
+
fd, tmp = tempfile.mkstemp(suffix=".wav.tmp", dir=out_dir)
|
|
140
|
+
try:
|
|
141
|
+
with os.fdopen(fd, "wb") as f:
|
|
142
|
+
f.write(wav)
|
|
143
|
+
os.replace(tmp, out_path)
|
|
144
|
+
except BaseException:
|
|
145
|
+
try:
|
|
146
|
+
os.unlink(tmp)
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
|
149
|
+
raise
|
|
150
|
+
print(f"sph2wav: wrote {out_path}", file=sys.stderr)
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
raise SystemExit(main())
|
desphere/codecs.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Sample-coding decoders and the capability gate.
|
|
2
|
+
|
|
3
|
+
``sample_coding`` in a SPHERE header looks like ``pcm``, ``ulaw``, ``alaw``, or
|
|
4
|
+
a base coding plus a compression token, e.g. ``pcm,embedded-shorten-v2.00``.
|
|
5
|
+
|
|
6
|
+
:func:`resolve_codec` is the single place that decides whether we can handle a
|
|
7
|
+
file. The design goal (per project intent) is: support the obvious, clearly
|
|
8
|
+
documented, lossless path first, and **fail loudly** on everything else so we
|
|
9
|
+
never emit a plausible-but-wrong WAV. New variants slot in by registering a
|
|
10
|
+
decoder; until then the gate raises a precise error.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import struct
|
|
16
|
+
from typing import Tuple
|
|
17
|
+
|
|
18
|
+
from . import g711, shorten
|
|
19
|
+
from .errors import (
|
|
20
|
+
DesphereError,
|
|
21
|
+
SphereHeaderError,
|
|
22
|
+
UnsupportedCoding,
|
|
23
|
+
UnsupportedFormat,
|
|
24
|
+
)
|
|
25
|
+
from .sphere import SphereHeader
|
|
26
|
+
|
|
27
|
+
# Optional Rust accelerator. Only the heavy numeric kernels (shorten decode and
|
|
28
|
+
# G.711 expansion) are delegated; the typed validation/error checks stay here, so
|
|
29
|
+
# behavior is identical with or without it. PCM byte-reorder stays pure (the
|
|
30
|
+
# strided slice is already C-speed). pip install desphere[fast] to enable.
|
|
31
|
+
try:
|
|
32
|
+
import desphere_native as _native
|
|
33
|
+
except ImportError: # pragma: no cover - native is optional
|
|
34
|
+
_native = None
|
|
35
|
+
|
|
36
|
+
# Map NIST sample_byte_format -> endianness of the stored samples.
|
|
37
|
+
# "01" = low byte first (little-endian)
|
|
38
|
+
# "10" = high byte first (big-endian)
|
|
39
|
+
# "1" = single byte (order irrelevant)
|
|
40
|
+
_BYTE_ORDER = {
|
|
41
|
+
"1": "little",
|
|
42
|
+
"01": "little",
|
|
43
|
+
"10": "big",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _to_little_endian(raw: bytes, n_bytes: int, order: str) -> bytes:
|
|
48
|
+
"""Return ``raw`` with each ``n_bytes``-wide sample in little-endian order."""
|
|
49
|
+
if n_bytes == 1 or order == "little":
|
|
50
|
+
return raw
|
|
51
|
+
# Reverse byte order within every n_bytes-sized group using strided slice
|
|
52
|
+
# assignment (C-speed, no numpy dependency).
|
|
53
|
+
out = bytearray(len(raw))
|
|
54
|
+
for i in range(n_bytes):
|
|
55
|
+
out[i::n_bytes] = raw[n_bytes - 1 - i::n_bytes]
|
|
56
|
+
return bytes(out)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PcmCodec:
|
|
60
|
+
"""Linear PCM: a lossless byte-order normalization to little-endian WAV.
|
|
61
|
+
|
|
62
|
+
Supports 16-bit and 32-bit samples (the overwhelming majority of SPHERE
|
|
63
|
+
corpora). 8-bit and 24-bit are deliberately rejected for now: their sign
|
|
64
|
+
and packing conventions have not been validated against an oracle, and a
|
|
65
|
+
wrong guess would silently corrupt audio.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
name = "pcm"
|
|
69
|
+
supported_n_bytes = (2, 4)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def decode(cls, header: SphereHeader, data: bytes) -> Tuple[int, bytes]:
|
|
73
|
+
n_bytes = header.sample_n_bytes
|
|
74
|
+
if n_bytes not in cls.supported_n_bytes:
|
|
75
|
+
raise UnsupportedFormat(
|
|
76
|
+
f"{n_bytes * 8}-bit PCM not supported yet "
|
|
77
|
+
f"(supported: {[n * 8 for n in cls.supported_n_bytes]} bit)"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
order = _BYTE_ORDER.get(header.sample_byte_format)
|
|
81
|
+
if order is None:
|
|
82
|
+
raise UnsupportedFormat(
|
|
83
|
+
f"unrecognized sample_byte_format {header.sample_byte_format!r} "
|
|
84
|
+
"(supported: '1', '01', '10')"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
little = _to_little_endian(data, n_bytes, order)
|
|
88
|
+
return n_bytes * 8, little
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _G711Codec:
|
|
92
|
+
"""Base for the two ITU-T G.711 companding laws (8-bit in, 16-bit out)."""
|
|
93
|
+
|
|
94
|
+
name = "g711"
|
|
95
|
+
table: list = []
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def decode(cls, header: SphereHeader, data: bytes) -> Tuple[int, bytes]:
|
|
99
|
+
if header.sample_n_bytes != 1:
|
|
100
|
+
raise UnsupportedFormat(
|
|
101
|
+
f"{cls.name} expects 1-byte samples, got "
|
|
102
|
+
f"sample_n_bytes={header.sample_n_bytes}"
|
|
103
|
+
)
|
|
104
|
+
if _native is not None:
|
|
105
|
+
return 16, bytes(_native.g711_expand(bytes(data), cls.name == "alaw"))
|
|
106
|
+
return 16, g711.expand(data, cls.table)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class UlawCodec(_G711Codec):
|
|
110
|
+
name = "ulaw"
|
|
111
|
+
table = g711.ULAW_TABLE
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AlawCodec(_G711Codec):
|
|
115
|
+
name = "alaw"
|
|
116
|
+
table = g711.ALAW_TABLE
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ShortenCodec:
|
|
120
|
+
"""Embedded-shorten (v2) lossless decompression to little-endian PCM.
|
|
121
|
+
|
|
122
|
+
Handles 16-bit PCM shorten types, the lossless mu-law mode (type 8, including
|
|
123
|
+
BITSHIFT, expanded to 16-bit PCM via G.711), and QLPC (LPC-predicted) blocks.
|
|
124
|
+
Only unsupported shorten sample types raise a precise error.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
name = "embedded-shorten-v2.00"
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def decode(cls, header: SphereHeader, data: bytes) -> Tuple[int, bytes]:
|
|
131
|
+
if _native is not None:
|
|
132
|
+
# Native does the heavy decode+expand and returns PCM bytes; we keep
|
|
133
|
+
# the same typed header cross-checks (on the emitted PCM, 2 B/sample).
|
|
134
|
+
try:
|
|
135
|
+
nchan, _is_ulaw, pcm = _native.shorten_to_pcm(bytes(data))
|
|
136
|
+
except ValueError as exc:
|
|
137
|
+
raise DesphereError(str(exc)) from exc
|
|
138
|
+
pcm = bytes(pcm)
|
|
139
|
+
if nchan != header.channel_count:
|
|
140
|
+
raise UnsupportedFormat(
|
|
141
|
+
f"shorten channel count {nchan} disagrees with SPHERE header "
|
|
142
|
+
f"channel_count {header.channel_count}"
|
|
143
|
+
)
|
|
144
|
+
expected = header.sample_count * nchan # in samples
|
|
145
|
+
got = len(pcm) // 2
|
|
146
|
+
if got < expected:
|
|
147
|
+
raise SphereHeaderError(
|
|
148
|
+
f"shorten stream decoded {got // nchan} samples/channel, "
|
|
149
|
+
f"but the SPHERE header declares {header.sample_count} "
|
|
150
|
+
"(stream truncated or QUIT came early)"
|
|
151
|
+
)
|
|
152
|
+
return 16, (pcm[: expected * 2] if got > expected else pcm)
|
|
153
|
+
|
|
154
|
+
values, kind, nchan = shorten.decode(data)
|
|
155
|
+
if nchan != header.channel_count:
|
|
156
|
+
raise UnsupportedFormat(
|
|
157
|
+
f"shorten channel count {nchan} disagrees with SPHERE header "
|
|
158
|
+
f"channel_count {header.channel_count}"
|
|
159
|
+
)
|
|
160
|
+
# Cross-check the decoded length against the header, mirroring the PCM
|
|
161
|
+
# path's truncation guard: a stream that QUITs early would otherwise yield
|
|
162
|
+
# a silently-short WAV. Excess (final-block padding) is trimmed.
|
|
163
|
+
expected = header.sample_count * nchan
|
|
164
|
+
if len(values) < expected:
|
|
165
|
+
raise SphereHeaderError(
|
|
166
|
+
f"shorten stream decoded {len(values) // nchan} samples/channel, "
|
|
167
|
+
f"but the SPHERE header declares {header.sample_count} "
|
|
168
|
+
"(stream truncated or QUIT came early)"
|
|
169
|
+
)
|
|
170
|
+
if len(values) > expected:
|
|
171
|
+
values = values[:expected]
|
|
172
|
+
if kind == "ulaw":
|
|
173
|
+
return 16, g711.expand(bytes(values), g711.ULAW_TABLE)
|
|
174
|
+
# kind == "pcm16": signed 16-bit little-endian
|
|
175
|
+
lo, hi = -32768, 32767
|
|
176
|
+
clipped = [lo if v < lo else hi if v > hi else v for v in values]
|
|
177
|
+
return 16, struct.pack("<%dh" % len(clipped), *clipped)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Registry of base codings we can decode. Compression tokens are gated
|
|
181
|
+
# separately so unsupported compressions fail with a clear message.
|
|
182
|
+
# Aliases cover the spellings different encoders write into the header.
|
|
183
|
+
_BASE_CODECS = {
|
|
184
|
+
"pcm": PcmCodec,
|
|
185
|
+
"ulaw": UlawCodec,
|
|
186
|
+
"mu-law": UlawCodec,
|
|
187
|
+
"mulaw": UlawCodec,
|
|
188
|
+
"alaw": AlawCodec,
|
|
189
|
+
"a-law": AlawCodec,
|
|
190
|
+
}
|
|
191
|
+
_COMPRESSORS = {
|
|
192
|
+
"embedded-shorten-v2.00": ShortenCodec,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def resolve_codec(header: SphereHeader):
|
|
197
|
+
"""Return a codec for ``header`` or raise a precise Unsupported* error."""
|
|
198
|
+
tokens = [t.strip().lower() for t in header.sample_coding.split(",")]
|
|
199
|
+
base = tokens[0]
|
|
200
|
+
compression = tokens[1] if len(tokens) > 1 else None
|
|
201
|
+
|
|
202
|
+
if compression:
|
|
203
|
+
if compression not in _COMPRESSORS:
|
|
204
|
+
raise UnsupportedCoding(
|
|
205
|
+
f"compressed coding {header.sample_coding!r} not supported yet "
|
|
206
|
+
f"(compression: {compression!r})"
|
|
207
|
+
)
|
|
208
|
+
# Future: return a decoder that decompresses, then applies the base codec.
|
|
209
|
+
return _COMPRESSORS[compression]
|
|
210
|
+
|
|
211
|
+
codec = _BASE_CODECS.get(base)
|
|
212
|
+
if codec is None:
|
|
213
|
+
raise UnsupportedCoding(
|
|
214
|
+
f"sample_coding {base!r} not supported yet "
|
|
215
|
+
f"(supported: {sorted(_BASE_CODECS)})"
|
|
216
|
+
)
|
|
217
|
+
return codec
|
desphere/errors.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Exception hierarchy for desphere.
|
|
2
|
+
|
|
3
|
+
Everything that goes wrong raises a subclass of :class:`DesphereError`, so a
|
|
4
|
+
caller (or the ``sph2wav`` CLI) can catch one type and always get an actionable
|
|
5
|
+
message instead of a stack trace or, worse, a plausible-but-wrong WAV.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DesphereError(Exception):
|
|
12
|
+
"""Base class for all desphere errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SphereHeaderError(DesphereError):
|
|
16
|
+
"""The NIST SPHERE header is missing, malformed, or internally inconsistent.
|
|
17
|
+
|
|
18
|
+
Also raised when the audio payload is shorter than the header claims.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnsupportedCoding(DesphereError):
|
|
23
|
+
"""The ``sample_coding`` is structurally valid but not implemented.
|
|
24
|
+
|
|
25
|
+
Examples: a base coding desphere does not recognize, or a compression token
|
|
26
|
+
other than ``embedded-shorten-v2.00``. We fail loudly rather than guess.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnsupportedFormat(DesphereError):
|
|
31
|
+
"""A structurally valid field describes a layout we have not validated.
|
|
32
|
+
|
|
33
|
+
Examples: 8-bit or 24-bit PCM, or an unrecognized ``sample_byte_format``.
|
|
34
|
+
"""
|
desphere/g711.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""ITU-T G.711 mu-law / a-law expansion to 16-bit linear PCM.
|
|
2
|
+
|
|
3
|
+
Implemented from the **ITU-T G.711** recommendation (a public telecom standard
|
|
4
|
+
that defines the companding tables); no GPL/LGPL source was consulted. The
|
|
5
|
+
decode is a fixed 256-entry table, so we precompute it once at import.
|
|
6
|
+
|
|
7
|
+
Both laws expand an 8-bit companded code to a signed 16-bit linear sample.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import array
|
|
13
|
+
import sys
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
_BIAS = 0x84 # 132; the mu-law magnitude bias
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ulaw_to_linear(u_val: int) -> int:
|
|
20
|
+
"""Expand one mu-law byte to a signed 16-bit sample (ITU-T G.711)."""
|
|
21
|
+
u_val = ~u_val & 0xFF
|
|
22
|
+
t = ((u_val & 0x0F) << 3) + _BIAS
|
|
23
|
+
t <<= (u_val & 0x70) >> 4
|
|
24
|
+
return (_BIAS - t) if (u_val & 0x80) else (t - _BIAS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _alaw_to_linear(a_val: int) -> int:
|
|
28
|
+
"""Expand one a-law byte to a signed 16-bit sample (ITU-T G.711)."""
|
|
29
|
+
a_val ^= 0x55
|
|
30
|
+
mantissa = a_val & 0x0F
|
|
31
|
+
segment = (a_val & 0x70) >> 4
|
|
32
|
+
if segment == 0:
|
|
33
|
+
t = (mantissa << 4) + 8
|
|
34
|
+
else:
|
|
35
|
+
t = ((mantissa << 4) + 0x108) << (segment - 1)
|
|
36
|
+
return t if (a_val & 0x80) else -t
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Precomputed expansion tables: code (0..255) -> signed 16-bit sample.
|
|
40
|
+
ULAW_TABLE: List[int] = [_ulaw_to_linear(i) for i in range(256)]
|
|
41
|
+
ALAW_TABLE: List[int] = [_alaw_to_linear(i) for i in range(256)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def expand(data: bytes, table: List[int]) -> bytes:
|
|
45
|
+
"""Expand companded ``data`` to little-endian 16-bit PCM via ``table``."""
|
|
46
|
+
samples = array.array("h", [table[b] for b in data])
|
|
47
|
+
if sys.byteorder == "big":
|
|
48
|
+
samples.byteswap() # WAV/RIFF is little-endian
|
|
49
|
+
return samples.tobytes()
|