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 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()