mea8000-encoder 0.1.0__tar.gz

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.
Files changed (31) hide show
  1. mea8000_encoder-0.1.0/LICENSE +21 -0
  2. mea8000_encoder-0.1.0/MANIFEST.in +2 -0
  3. mea8000_encoder-0.1.0/PKG-INFO +142 -0
  4. mea8000_encoder-0.1.0/README.md +114 -0
  5. mea8000_encoder-0.1.0/THIRD_PARTY.md +41 -0
  6. mea8000_encoder-0.1.0/mea8000/__init__.py +6 -0
  7. mea8000_encoder-0.1.0/mea8000/__main__.py +7 -0
  8. mea8000_encoder-0.1.0/mea8000/analysis.py +326 -0
  9. mea8000_encoder-0.1.0/mea8000/cli.py +287 -0
  10. mea8000_encoder-0.1.0/mea8000/codec.py +221 -0
  11. mea8000_encoder-0.1.0/mea8000/encoder.py +793 -0
  12. mea8000_encoder-0.1.0/mea8000/fastsim.py +260 -0
  13. mea8000_encoder-0.1.0/mea8000/filters.py +114 -0
  14. mea8000_encoder-0.1.0/mea8000/merge.py +129 -0
  15. mea8000_encoder-0.1.0/mea8000/profile.py +316 -0
  16. mea8000_encoder-0.1.0/mea8000/profiles/compact.toml +11 -0
  17. mea8000_encoder-0.1.0/mea8000/profiles/philips.toml +10 -0
  18. mea8000_encoder-0.1.0/mea8000/profiles/thomson.toml +21 -0
  19. mea8000_encoder-0.1.0/mea8000/report.py +254 -0
  20. mea8000_encoder-0.1.0/mea8000/sim.py +427 -0
  21. mea8000_encoder-0.1.0/mea8000/tables.py +34 -0
  22. mea8000_encoder-0.1.0/mea8000/tuning.py +96 -0
  23. mea8000_encoder-0.1.0/mea8000/wav.py +83 -0
  24. mea8000_encoder-0.1.0/mea8000_encoder.egg-info/PKG-INFO +142 -0
  25. mea8000_encoder-0.1.0/mea8000_encoder.egg-info/SOURCES.txt +29 -0
  26. mea8000_encoder-0.1.0/mea8000_encoder.egg-info/dependency_links.txt +1 -0
  27. mea8000_encoder-0.1.0/mea8000_encoder.egg-info/entry_points.txt +2 -0
  28. mea8000_encoder-0.1.0/mea8000_encoder.egg-info/requires.txt +8 -0
  29. mea8000_encoder-0.1.0/mea8000_encoder.egg-info/top_level.txt +1 -0
  30. mea8000_encoder-0.1.0/pyproject.toml +44 -0
  31. mea8000_encoder-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wide-dot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ # the tests need the samples, which are not in the package: repository only
2
+ prune tests
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: mea8000-encoder
3
+ Version: 0.1.0
4
+ Summary: A recording in, MEA8000 speech data out, with a preview of what the chip will say
5
+ Author: wide-dot
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/wide-dot/mea8000-encoder
8
+ Project-URL: Documentation, https://github.com/wide-dot/mea8000-encoder#readme
9
+ Keywords: MEA8000,speech synthesis,Thomson,6809,retro
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
16
+ Classifier: Topic :: System :: Emulators
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ License-File: THIRD_PARTY.md
21
+ Requires-Dist: numpy>=2.0
22
+ Requires-Dist: scipy>=1.14
23
+ Provides-Extra: fast
24
+ Requires-Dist: numba>=0.60; extra == "fast"
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # mea8000-encoder
30
+
31
+ A recording in, MEA8000 speech data out — in the period file formats — with a preview
32
+ of what the chip will say. The MEA8000 is the formant speech synthesizer of the 1980s
33
+ (Philips / Signetics) found in the Cedic-Nathan speech box of Thomson computers and in
34
+ other machines of the time.
35
+
36
+ ## Try it in two minutes
37
+
38
+ Python 3.12 or later, on macOS, Linux or Windows:
39
+
40
+ ```
41
+ pip install mea8000-encoder
42
+ mea8000 encode samples/fr-female.wav fr-female.mea --report
43
+ ```
44
+
45
+ `fr-female.mea` is the speech data. `fr-female.html` opens in a browser: the recording
46
+ and the chip's rendering side by side, with a button to play each (TAB switches while
47
+ playing), the chip's resonators as bands on their own lane, the pitch, level and voicing
48
+ lanes, and the frames. Beside it,
49
+ `fr-female-source.wav` is the recording as the encoder saw it (one channel, normalized,
50
+ the silence around the speech dropped — see the profile) and `fr-female-chip.wav` the
51
+ rendering alone.
52
+
53
+ `samples/` holds three sentences from Mozilla Common Voice, their speech data and their
54
+ reports.
55
+
56
+ ## The commands
57
+
58
+ | command | what |
59
+ |---|---|
60
+ | `mea8000 encode SOURCE.wav OUTPUT.mea [--profile P] [options]` | encode a recording (any WAV: any rate, bit depth, mono or stereo); `--report` for the page and the two WAVs |
61
+ | `mea8000 render INPUT.mea OUTPUT.wav [--profile P]` | hear what the chip says (a WAV at the chip's own rate; `--rate HZ` to resample) |
62
+ | `mea8000 inspect INPUT.mea [--summary]` | what a file holds: format, word groups, frames |
63
+ | `mea8000 profiles [NAME]` | the shipped profiles; with a name, that profile's file, to start yours from |
64
+
65
+ Both formats are recognised from the file's content (`--format` forces one). Encoding a
66
+ 30 s recording takes about 20 s; a percentage shows the progress. `python -m mea8000` is
67
+ the same command.
68
+
69
+ ## The profile
70
+
71
+ A profile says how to convert: for which machine, in which format, with which pitch
72
+ policy, frame length and quality, and through which filters (channel, high-pass,
73
+ normalization, trim). It is one small TOML file.
74
+ Three are shipped — `thomson` (the default), `philips`, `compact` — and you can write
75
+ your own: `--profile mine.toml`. Every value can also be overridden on the command line.
76
+
77
+ ```toml
78
+ clock_hz = 4000000 # the box clocks the chip at 4 MHz: everything would play 4.2 %
79
+ # faster and higher than Philips' 3.84 MHz reference; the encoder
80
+ # compensates so that the machine says what the recording says
81
+ format = "speech" # "speech": speech files one after the other; "vocabulary": an
82
+ # image with an offset table, each file reachable by its number
83
+ pitch = "local" # "local": a starting pitch per word group, each its own speech
84
+ # file; "global": one for the whole recording, gliding through
85
+ # the pauses, in a single speech file
86
+ frame_ms = 8 # the shortest frame allowed: 8 (the chip's finest), 16, 32, 64
87
+ quality = "best" # how much degradation is accepted to lengthen frames beyond
88
+ # frame_ms: "best" none, "balanced" a little (about 40 % fewer
89
+ # bytes), "compact" what stays intelligible (about 60 % fewer)
90
+ channel = "left" # which channel of a stereo recording: "left", "right", "mix"
91
+ highpass_hz = 0 # a high-pass on the recording before analysis (80-150 removes
92
+ # rumble and kick drums); 0: none
93
+ normalize = true # scale the recording so that its loudest sample reaches full
94
+ # scale (the chip's amplitude codes are absolute: a quiet
95
+ # recording would come out as silence)
96
+ trim = true # drop the silence before the first sound and after the last
97
+ # (they would cost silent frames and a wait before the word)
98
+ ```
99
+
100
+ The last four are filters: they produce the recording the encoder actually sees, the one
101
+ `--report` writes as `-source.wav` and compares the chip against.
102
+
103
+ The same file can carry decisions on passages of one recording (`[[pin]]`: force a
104
+ passage to silence or to a pitch, filter it, start a new speech file there…) and, for
105
+ whoever experiments, the encoder's internal constants (`[tuning]`). Everything a profile
106
+ can say is in [docs/profile.md](docs/profile.md), written to be read and written by a
107
+ person or a program.
108
+
109
+ ## The formats
110
+
111
+ `encode` writes the original, official formats of the chip's era: **speech files** (`.mea`,
112
+ Philips Technical Publication 101, the format of the Cedic-Nathan data) one after the
113
+ other, or a **vocabulary image** (`.voc.mea`, the layout of the Cedic-Nathan cartridges: an
114
+ offset table, then the speech files). Both are documented byte by byte in
115
+ [docs/formats/](docs/formats/), with the chip's protocol to play them.
116
+
117
+ ## How it works
118
+
119
+ The chip plays frames of 4 bytes lasting 8 to 64 ms: a pitch step, an amplitude, and
120
+ four resonators (three formants and a fixed one) with a bandwidth each, excited by a
121
+ sawtooth (voiced sound) or by noise. The encoder analyses the recording every 8 ms
122
+ (level, voicing, pitch); for each 8 ms it searches all 2 097 152 resonator settings for
123
+ the one whose synthesized spectrum is closest to the recording, smooths the choices over
124
+ time so that formants move the way speech does, and picks the amplitude code that
125
+ reproduces the level. Silences become silent frames; a pause starts a new word group.
126
+
127
+ It works on clean speech, spoken or sung, in any language. It does not turn music into
128
+ speech. The chip's range is 100-4000 Hz: what is above is lost, what is below (a kick
129
+ drum) should be filtered out first (`--highpass`).
130
+
131
+ ## The simulator
132
+
133
+ `mea8000/sim.py` is a register-level model of the chip derived from Antoine Miné's MAME
134
+ device (BSD-3, see [THIRD_PARTY.md](THIRD_PARTY.md)), with two corrections of that device
135
+ (the amplitude division that silenced the quietest codes, and the inverted fade-in of
136
+ the first frame — both also fixed in dcmoto 2024). `fastsim.py` is the same model compiled
137
+ by numba, bit-exact; the plain model already renders 30 s in about 2 s, so the
138
+ `[fast]` extra (`pip install mea8000-encoder[fast]`) is a comfort, not a need.
139
+
140
+ ## License
141
+
142
+ MIT. The samples come from Mozilla Common Voice, CC0 (see [samples/README.md](samples/README.md)).
@@ -0,0 +1,114 @@
1
+ # mea8000-encoder
2
+
3
+ A recording in, MEA8000 speech data out — in the period file formats — with a preview
4
+ of what the chip will say. The MEA8000 is the formant speech synthesizer of the 1980s
5
+ (Philips / Signetics) found in the Cedic-Nathan speech box of Thomson computers and in
6
+ other machines of the time.
7
+
8
+ ## Try it in two minutes
9
+
10
+ Python 3.12 or later, on macOS, Linux or Windows:
11
+
12
+ ```
13
+ pip install mea8000-encoder
14
+ mea8000 encode samples/fr-female.wav fr-female.mea --report
15
+ ```
16
+
17
+ `fr-female.mea` is the speech data. `fr-female.html` opens in a browser: the recording
18
+ and the chip's rendering side by side, with a button to play each (TAB switches while
19
+ playing), the chip's resonators as bands on their own lane, the pitch, level and voicing
20
+ lanes, and the frames. Beside it,
21
+ `fr-female-source.wav` is the recording as the encoder saw it (one channel, normalized,
22
+ the silence around the speech dropped — see the profile) and `fr-female-chip.wav` the
23
+ rendering alone.
24
+
25
+ `samples/` holds three sentences from Mozilla Common Voice, their speech data and their
26
+ reports.
27
+
28
+ ## The commands
29
+
30
+ | command | what |
31
+ |---|---|
32
+ | `mea8000 encode SOURCE.wav OUTPUT.mea [--profile P] [options]` | encode a recording (any WAV: any rate, bit depth, mono or stereo); `--report` for the page and the two WAVs |
33
+ | `mea8000 render INPUT.mea OUTPUT.wav [--profile P]` | hear what the chip says (a WAV at the chip's own rate; `--rate HZ` to resample) |
34
+ | `mea8000 inspect INPUT.mea [--summary]` | what a file holds: format, word groups, frames |
35
+ | `mea8000 profiles [NAME]` | the shipped profiles; with a name, that profile's file, to start yours from |
36
+
37
+ Both formats are recognised from the file's content (`--format` forces one). Encoding a
38
+ 30 s recording takes about 20 s; a percentage shows the progress. `python -m mea8000` is
39
+ the same command.
40
+
41
+ ## The profile
42
+
43
+ A profile says how to convert: for which machine, in which format, with which pitch
44
+ policy, frame length and quality, and through which filters (channel, high-pass,
45
+ normalization, trim). It is one small TOML file.
46
+ Three are shipped — `thomson` (the default), `philips`, `compact` — and you can write
47
+ your own: `--profile mine.toml`. Every value can also be overridden on the command line.
48
+
49
+ ```toml
50
+ clock_hz = 4000000 # the box clocks the chip at 4 MHz: everything would play 4.2 %
51
+ # faster and higher than Philips' 3.84 MHz reference; the encoder
52
+ # compensates so that the machine says what the recording says
53
+ format = "speech" # "speech": speech files one after the other; "vocabulary": an
54
+ # image with an offset table, each file reachable by its number
55
+ pitch = "local" # "local": a starting pitch per word group, each its own speech
56
+ # file; "global": one for the whole recording, gliding through
57
+ # the pauses, in a single speech file
58
+ frame_ms = 8 # the shortest frame allowed: 8 (the chip's finest), 16, 32, 64
59
+ quality = "best" # how much degradation is accepted to lengthen frames beyond
60
+ # frame_ms: "best" none, "balanced" a little (about 40 % fewer
61
+ # bytes), "compact" what stays intelligible (about 60 % fewer)
62
+ channel = "left" # which channel of a stereo recording: "left", "right", "mix"
63
+ highpass_hz = 0 # a high-pass on the recording before analysis (80-150 removes
64
+ # rumble and kick drums); 0: none
65
+ normalize = true # scale the recording so that its loudest sample reaches full
66
+ # scale (the chip's amplitude codes are absolute: a quiet
67
+ # recording would come out as silence)
68
+ trim = true # drop the silence before the first sound and after the last
69
+ # (they would cost silent frames and a wait before the word)
70
+ ```
71
+
72
+ The last four are filters: they produce the recording the encoder actually sees, the one
73
+ `--report` writes as `-source.wav` and compares the chip against.
74
+
75
+ The same file can carry decisions on passages of one recording (`[[pin]]`: force a
76
+ passage to silence or to a pitch, filter it, start a new speech file there…) and, for
77
+ whoever experiments, the encoder's internal constants (`[tuning]`). Everything a profile
78
+ can say is in [docs/profile.md](docs/profile.md), written to be read and written by a
79
+ person or a program.
80
+
81
+ ## The formats
82
+
83
+ `encode` writes the original, official formats of the chip's era: **speech files** (`.mea`,
84
+ Philips Technical Publication 101, the format of the Cedic-Nathan data) one after the
85
+ other, or a **vocabulary image** (`.voc.mea`, the layout of the Cedic-Nathan cartridges: an
86
+ offset table, then the speech files). Both are documented byte by byte in
87
+ [docs/formats/](docs/formats/), with the chip's protocol to play them.
88
+
89
+ ## How it works
90
+
91
+ The chip plays frames of 4 bytes lasting 8 to 64 ms: a pitch step, an amplitude, and
92
+ four resonators (three formants and a fixed one) with a bandwidth each, excited by a
93
+ sawtooth (voiced sound) or by noise. The encoder analyses the recording every 8 ms
94
+ (level, voicing, pitch); for each 8 ms it searches all 2 097 152 resonator settings for
95
+ the one whose synthesized spectrum is closest to the recording, smooths the choices over
96
+ time so that formants move the way speech does, and picks the amplitude code that
97
+ reproduces the level. Silences become silent frames; a pause starts a new word group.
98
+
99
+ It works on clean speech, spoken or sung, in any language. It does not turn music into
100
+ speech. The chip's range is 100-4000 Hz: what is above is lost, what is below (a kick
101
+ drum) should be filtered out first (`--highpass`).
102
+
103
+ ## The simulator
104
+
105
+ `mea8000/sim.py` is a register-level model of the chip derived from Antoine Miné's MAME
106
+ device (BSD-3, see [THIRD_PARTY.md](THIRD_PARTY.md)), with two corrections of that device
107
+ (the amplitude division that silenced the quietest codes, and the inverted fade-in of
108
+ the first frame — both also fixed in dcmoto 2024). `fastsim.py` is the same model compiled
109
+ by numba, bit-exact; the plain model already renders 30 s in about 2 s, so the
110
+ `[fast]` extra (`pip install mea8000-encoder[fast]`) is a comfort, not a need.
111
+
112
+ ## License
113
+
114
+ MIT. The samples come from Mozilla Common Voice, CC0 (see [samples/README.md](samples/README.md)).
@@ -0,0 +1,41 @@
1
+ # Third-party notices
2
+
3
+ ## MAME MEA8000 emulation
4
+
5
+ `mea8000/sim.py` and `mea8000/fastsim.py` reproduce, operation for operation, the MEA8000
6
+ device of MAME written by Antoine Miné (`src/devices/sound/mea8000.cpp`), with two
7
+ corrections (the amplitude division and the fade-in of the first frame, see the module
8
+ docstrings). MAME's device is distributed under the BSD-3-Clause license:
9
+
10
+ ```
11
+ Copyright (c) Antoine Mine and the MAME team
12
+
13
+ Redistribution and use in source and binary forms, with or without
14
+ modification, are permitted provided that the following conditions are met:
15
+
16
+ 1. Redistributions of source code must retain the above copyright notice,
17
+ this list of conditions and the following disclaimer.
18
+ 2. Redistributions in binary form must reproduce the above copyright notice,
19
+ this list of conditions and the following disclaimer in the documentation
20
+ and/or other materials provided with the distribution.
21
+ 3. Neither the name of the copyright holder nor the names of its contributors
22
+ may be used to endorse or promote products derived from this software
23
+ without specific prior written permission.
24
+
25
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
26
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
27
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
28
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
29
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
30
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
31
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
32
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
33
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
34
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
35
+ POSSIBILITY OF SUCH DAMAGE.
36
+ ```
37
+
38
+ ## Samples
39
+
40
+ The recordings in `samples/` come from Mozilla Common Voice 17.0 and are released under
41
+ the CC0 1.0 Universal public domain dedication; see `samples/README.md`.
@@ -0,0 +1,6 @@
1
+ """MEA8000 speech synthesizer tools."""
2
+
3
+ from .codec import Frame, Utterance, parse_stream, build_stream
4
+ from .sim import Chip, Model, render
5
+
6
+ __all__ = ["Frame", "Utterance", "parse_stream", "build_stream", "Chip", "Model", "render"]
@@ -0,0 +1,7 @@
1
+ """`python -m mea8000` is the `mea8000` command."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,326 @@
1
+ """Target analysis for the encoder: pitch, voicing, energy and spectral envelope per 8 ms.
2
+
3
+ Everything works at 8 kHz, the rate of the chip's filter bank: nothing above 4 kHz can be
4
+ represented, so the input is band-limited first.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+ from scipy.signal import resample_poly
13
+
14
+ RATE = 8000
15
+ HOP = 64 # 8 ms, the shortest frame of the chip
16
+ ENV_WIN = 256 # 32 ms analysis window for the envelope
17
+ PITCH_WIN = 224 # 28 ms window for the difference function
18
+ MAX_CANDIDATES = 6
19
+ F0_MIN, F0_MAX = 60.0, 510.0
20
+ N_BINS = 129 # envelope grid: 0..4000 Hz in 31.25 Hz steps
21
+ ENV_FREQS = np.linspace(0.0, RATE / 2, N_BINS)
22
+
23
+
24
+ @dataclass
25
+ class Analysis:
26
+ rate: int
27
+ hop: int
28
+ f0: np.ndarray # Hz per frame, nan when unvoiced
29
+ voiced: np.ndarray # bool per frame
30
+ periodicity: np.ndarray # 1 - min(cmndf), per frame
31
+ energy_db: np.ndarray # RMS of the frame, dB re full scale
32
+ envelope_db: np.ndarray # (frames, N_BINS) smoothed log magnitude
33
+ lpc: np.ndarray # (frames, order + 1) predictor coefficients
34
+ hnr_db: np.ndarray | None = None # harmonic-to-noise ratio at the tracked pitch
35
+
36
+ @property
37
+ def n_frames(self) -> int:
38
+ return len(self.f0)
39
+
40
+ def times(self) -> np.ndarray:
41
+ return np.arange(self.n_frames) * self.hop / self.rate
42
+
43
+
44
+ def to_8k(x: np.ndarray, rate: float) -> np.ndarray:
45
+ """Mono float signal at 8 kHz; `rate` may be fractional (clock compensation)."""
46
+ x = np.asarray(x, dtype=np.float64)
47
+ if x.dtype.kind in "iu" or np.abs(x).max() > 1.5:
48
+ x = x / 32768.0
49
+ rate = int(round(rate))
50
+ if rate == RATE:
51
+ return x
52
+ from math import gcd
53
+
54
+ g = gcd(rate, RATE)
55
+ return resample_poly(x, RATE // g, rate // g)
56
+
57
+
58
+ def n_frames_for(n_samples: int, hop: int = HOP) -> int:
59
+ return max(1, int(np.ceil(n_samples / hop)))
60
+
61
+
62
+ def _frame(x: np.ndarray, start: int, win: int) -> np.ndarray:
63
+ """Window of `win` samples centred on the frame that starts at `start` (zero padded)."""
64
+ c = start + HOP // 2
65
+ a = c - win // 2
66
+ seg = np.zeros(win)
67
+ lo, hi = max(a, 0), min(a + win, len(x))
68
+ if hi > lo:
69
+ seg[lo - a: hi - a] = x[lo:hi]
70
+ return seg
71
+
72
+
73
+ # ------------------------------------------------------------------ envelope (Burg LPC)
74
+
75
+ def burg(x: np.ndarray, order: int) -> tuple[np.ndarray, float]:
76
+ """Burg's method. Returns a[0..order] with a[0] = 1 and the prediction error power."""
77
+ x = np.asarray(x, dtype=np.float64)
78
+ n = len(x)
79
+ a = np.zeros(order + 1)
80
+ a[0] = 1.0
81
+ err = float(np.dot(x, x)) / n
82
+ f = x[1:].copy() # forward prediction errors
83
+ b = x[:-1].copy() # backward prediction errors
84
+ for m in range(1, order + 1):
85
+ den = float(np.dot(f, f) + np.dot(b, b))
86
+ k = -2.0 * float(np.dot(f, b)) / den if den > 0 else 0.0
87
+ a_prev = a.copy()
88
+ for i in range(1, m):
89
+ a[i] = a_prev[i] + k * a_prev[m - i]
90
+ a[m] = k
91
+ f, b = f[1:] + k * b[1:], b[:-1] + k * f[:-1]
92
+ err *= (1.0 - k * k)
93
+ return a, err
94
+
95
+
96
+ def lpc_envelope_db(a: np.ndarray, gain: float, freqs: np.ndarray = ENV_FREQS) -> np.ndarray:
97
+ w = 2 * np.pi * freqs / RATE
98
+ k = np.arange(len(a))
99
+ denom = np.abs(np.exp(-1j * np.outer(w, k)) @ a)
100
+ return 20 * np.log10(gain / np.maximum(denom, 1e-9) + 1e-12)
101
+
102
+
103
+ # ------------------------------------------------------------------ pitch (YIN)
104
+
105
+ def cmndf(seg: np.ndarray, lag_max: int) -> np.ndarray:
106
+ """Cumulative mean normalised difference function of YIN for lags 0..lag_max."""
107
+ n = len(seg) - lag_max
108
+ x = seg[:n]
109
+ d = np.empty(lag_max + 1)
110
+ e0 = float(np.dot(x, x))
111
+ # d(tau) = sum (x[j] - x[j+tau])^2 over j < n
112
+ csum = np.concatenate([[0.0], np.cumsum(seg * seg)])
113
+ for tau in range(lag_max + 1):
114
+ y = seg[tau: tau + n]
115
+ d[tau] = e0 + (csum[tau + n] - csum[tau]) - 2.0 * float(np.dot(x, y))
116
+ out = np.ones(lag_max + 1)
117
+ run = np.cumsum(d[1:])
118
+ with np.errstate(divide="ignore", invalid="ignore"):
119
+ out[1:] = np.where(run > 0, d[1:] * np.arange(1, lag_max + 1) / run, 1.0)
120
+ return out
121
+
122
+
123
+ def _parabolic(y: np.ndarray, i: int) -> float:
124
+ if i <= 0 or i >= len(y) - 1:
125
+ return float(i)
126
+ a, b, c = y[i - 1], y[i], y[i + 1]
127
+ den = a - 2 * b + c
128
+ return float(i) if den == 0 else float(i + 0.5 * (a - c) / den)
129
+
130
+
131
+ # the subharmonic threshold and penalty live in tuning.current (subharmonic_threshold, _penalty)
132
+
133
+
134
+ def _candidates(c: np.ndarray, lag_min: int) -> tuple[np.ndarray, np.ndarray]:
135
+ """Local minima of the CMNDF (refined lag, cost), best first, at most MAX_CANDIDATES.
136
+
137
+ YIN's protection against pitch halving: on a periodic signal the CMNDF is as deep at
138
+ two periods as at one, so the shortest lag whose dip is under SUBHARMONIC_THRESHOLD
139
+ is taken as the period and every longer dip pays SUBHARMONIC_PENALTY per octave.
140
+ Without it the tracker halves the pitch of sung voices and of the chip's own output."""
141
+ idx = [i for i in range(max(lag_min, 1), len(c) - 1) if c[i] <= c[i - 1] and c[i] < c[i + 1]]
142
+ if not idx:
143
+ return np.zeros(0), np.zeros(0)
144
+ # a dip counts as "good" under the absolute threshold, or within 0.1 of the deepest
145
+ # one: on a fading note the dip at one period is shallower than 0.15 while the one at
146
+ # two periods is not, and the pitch would still be halved
147
+ from . import tuning
148
+ K = tuning.current
149
+ cmin = min(c[i] for i in idx)
150
+ good = [i for i in idx if c[i] < max(K.subharmonic_threshold, min(0.4, cmin + 0.1))]
151
+ first = min(good) if good else None
152
+ cost = {i: c[i] + (K.subharmonic_penalty * np.log2(i / first) if first is not None and i > 1.5 * first else 0.0)
153
+ for i in idx}
154
+ idx.sort(key=lambda i: cost[i])
155
+ idx = idx[:MAX_CANDIDATES]
156
+ return np.array([_parabolic(c, i) for i in idx]), np.array([cost[i] for i in idx])
157
+
158
+
159
+ def _viterbi(lags: list[np.ndarray], costs: list[np.ndarray], unvoiced_cost: np.ndarray | float,
160
+ octave_weight: float, switch_cost: float) -> np.ndarray:
161
+ """Pick one candidate (or unvoiced, returned as nan) per frame by dynamic programming.
162
+ `unvoiced_cost` may be one value per frame."""
163
+ n = len(lags)
164
+ uc = np.broadcast_to(np.asarray(unvoiced_cost, dtype=np.float64), (n,))
165
+ best = [None] * n
166
+ back = [None] * n
167
+ prev_score = None
168
+ prev_lags = None
169
+ for k in range(n):
170
+ lk = np.append(lags[k], np.nan) # last state = unvoiced
171
+ local = np.append(costs[k], uc[k])
172
+ if prev_score is None:
173
+ score = local
174
+ back[k] = np.full(len(lk), -1)
175
+ else:
176
+ m = len(prev_lags)
177
+ trans = np.full((m, len(lk)), switch_cost)
178
+ pv = ~np.isnan(prev_lags)
179
+ cv = ~np.isnan(lk)
180
+ both = np.outer(pv, cv)
181
+ ratio = np.abs(np.log2(np.outer(prev_lags, 1.0 / lk)))
182
+ trans[both] = octave_weight * ratio[both]
183
+ trans[np.outer(~pv, ~cv)] = 0.0
184
+ total = prev_score[:, None] + trans
185
+ back[k] = np.argmin(total, axis=0)
186
+ score = total[back[k], np.arange(len(lk))] + local
187
+ best[k] = score
188
+ prev_score, prev_lags = score, lk
189
+ path = np.full(n, np.nan)
190
+ j = int(np.argmin(best[-1]))
191
+ for k in range(n - 1, -1, -1):
192
+ path[k] = lags[k][j] if j < len(lags[k]) else np.nan
193
+ j = int(back[k][j])
194
+ return path
195
+
196
+
197
+ def analyze(x: np.ndarray, rate: int, order: int = 12, unvoiced_cost: float | None = None,
198
+ switch_cost: float | None = None, min_run: int | None = None, silence_db: float = -60.0,
199
+ hnr_unvoiced: float | None = None, hnr_voiced: float = float("inf"),
200
+ loud_range_db: float = 20.0, loud_unvoiced_bonus: float | None = None) -> Analysis:
201
+ from . import tuning
202
+ K = tuning.current
203
+ unvoiced_cost = K.unvoiced_cost if unvoiced_cost is None else unvoiced_cost
204
+ switch_cost = K.switch_cost if switch_cost is None else switch_cost
205
+ min_run = K.min_run if min_run is None else min_run
206
+ hnr_unvoiced = K.hnr_unvoiced if hnr_unvoiced is None else hnr_unvoiced
207
+ loud_unvoiced_bonus = K.loud_unvoiced_bonus if loud_unvoiced_bonus is None else loud_unvoiced_bonus
208
+ # hnr_voiced (promotion of unvoiced slots) is disabled by default: on the chip's own
209
+ # output, noise through narrow resonators shows a median HNR of 13 dB against 17 dB for
210
+ # voiced slots, so promotion creates far more false voicing than it repairs
211
+ x8 = to_8k(x, rate)
212
+ n = n_frames_for(len(x8))
213
+ lag_min = int(np.floor(RATE / F0_MAX))
214
+ lag_max = int(np.ceil(RATE / F0_MIN))
215
+
216
+ periodicity = np.zeros(n)
217
+ energy = np.full(n, silence_db)
218
+ env = np.zeros((n, N_BINS))
219
+ lpcs = np.zeros((n, order + 1))
220
+ hann_env = np.hanning(ENV_WIN)
221
+ cand_lags: list[np.ndarray] = []
222
+ cand_costs: list[np.ndarray] = []
223
+
224
+ for k in range(n):
225
+ start = k * HOP
226
+ seg = x8[start: start + HOP]
227
+ if len(seg):
228
+ r = float(np.sqrt(np.mean(seg * seg)))
229
+ energy[k] = 20 * np.log10(r) if r > 0 else silence_db
230
+
231
+ # envelope
232
+ w = _frame(x8, start, ENV_WIN) * hann_env
233
+ if np.dot(w, w) > 1e-12:
234
+ a, err = burg(w, order)
235
+ lpcs[k] = a
236
+ env[k] = lpc_envelope_db(a, np.sqrt(max(err, 1e-20)))
237
+ else:
238
+ env[k] = -120.0
239
+
240
+ # pitch candidates
241
+ p = _frame(x8, start, PITCH_WIN + lag_max)
242
+ if np.dot(p, p) < 1e-10 or energy[k] <= silence_db + 10:
243
+ cand_lags.append(np.zeros(0))
244
+ cand_costs.append(np.zeros(0))
245
+ continue
246
+ c = cmndf(p, lag_max)
247
+ lags, costs = _candidates(c, lag_min)
248
+ periodicity[k] = 1.0 - float(costs[0]) if len(costs) else 0.0
249
+ cand_lags.append(lags)
250
+ cand_costs.append(costs)
251
+
252
+ # loud frames are almost always vowels: the unvoiced state costs more near the peak level
253
+ # (reverberation and background lower the measured periodicity of real recordings)
254
+ peak = float(np.max(energy))
255
+ loudness = np.clip((energy - (peak - loud_range_db)) / loud_range_db, 0.0, 1.0)
256
+ uc = unvoiced_cost + loud_unvoiced_bonus * loudness
257
+ lag_path = _viterbi(cand_lags, cand_costs, uc, octave_weight=K.octave_weight, switch_cost=switch_cost)
258
+ f0 = RATE / lag_path
259
+ voiced = ~np.isnan(f0)
260
+
261
+ # harmonic-to-noise ratio at the tracked pitch (or the best candidate) decides the
262
+ # doubtful slots: periodic energy through narrow resonators is not voicing, and weak
263
+ # voiced slots at onsets still show harmonics
264
+ hnr = np.full(n, np.nan)
265
+ for k in range(n):
266
+ if energy[k] <= silence_db + 10:
267
+ continue
268
+ if voiced[k]:
269
+ fk = f0[k]
270
+ elif len(cand_lags[k]):
271
+ fk = RATE / cand_lags[k][0]
272
+ else:
273
+ continue
274
+ hnr[k] = harmonic_to_noise_db(x8, k * HOP, fk)
275
+ with np.errstate(invalid="ignore"):
276
+ voiced = np.where(hnr < hnr_unvoiced, False, voiced)
277
+ promote = (~voiced) & (hnr > hnr_voiced)
278
+ for k in np.where(promote)[0]:
279
+ f0[k] = RATE / cand_lags[k][0]
280
+ voiced = voiced | promote
281
+ voiced = _absorb_short_runs(voiced, min_run)
282
+ # slots voiced by absorption take the pitch of their nearest voiced neighbour
283
+ idx = np.where(~np.isnan(f0))[0]
284
+ if len(idx):
285
+ for k in np.where(voiced & np.isnan(f0))[0]:
286
+ f0[k] = f0[idx[np.argmin(np.abs(idx - k))]]
287
+ f0[~voiced] = np.nan
288
+ return Analysis(RATE, HOP, f0, voiced, periodicity, energy, env, lpcs, hnr)
289
+
290
+
291
+ def harmonic_to_noise_db(x8: np.ndarray, start: int, f0_hz: float, fmax: float = 2500.0) -> float:
292
+ """Mean level difference (dB) between the harmonic peaks of f0 and the valleys between
293
+ them, over the harmonics below `fmax`, on a window of three periods."""
294
+ if not np.isfinite(f0_hz) or f0_hz <= 0:
295
+ return float("nan")
296
+ win = max(256, int(round(3 * RATE / f0_hz)))
297
+ win += win % 2
298
+ seg = _frame(x8, start, win) * np.hanning(win)
299
+ nfft = 4096
300
+ spec = 20 * np.log10(np.abs(np.fft.rfft(seg, nfft)) + 1e-9)
301
+ grid = np.fft.rfftfreq(nfft, 1.0 / RATE)
302
+ diffs = []
303
+ k = 1
304
+ while (k + 0.5) * f0_hz < fmax:
305
+ fc = k * f0_hz
306
+ lo, hi = np.searchsorted(grid, fc - 0.25 * f0_hz), np.searchsorted(grid, fc + 0.25 * f0_hz)
307
+ vlo, vhi = np.searchsorted(grid, fc + 0.3 * f0_hz), np.searchsorted(grid, fc + 0.7 * f0_hz)
308
+ if hi > lo and vhi > vlo:
309
+ diffs.append(spec[lo:hi].max() - spec[vlo:vhi].min())
310
+ k += 1
311
+ return float(np.mean(diffs)) if diffs else float("nan")
312
+
313
+
314
+ def _absorb_short_runs(flags: np.ndarray, min_run: int) -> np.ndarray:
315
+ """Flip runs shorter than `min_run` that sit between two runs of the other value."""
316
+ out = flags.copy()
317
+ n = len(out)
318
+ k = 0
319
+ while k < n:
320
+ j = k
321
+ while j < n and out[j] == out[k]:
322
+ j += 1
323
+ if 0 < k and j < n and j - k < min_run:
324
+ out[k:j] = not out[k]
325
+ k = j
326
+ return out