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.
- mea8000_encoder-0.1.0/LICENSE +21 -0
- mea8000_encoder-0.1.0/MANIFEST.in +2 -0
- mea8000_encoder-0.1.0/PKG-INFO +142 -0
- mea8000_encoder-0.1.0/README.md +114 -0
- mea8000_encoder-0.1.0/THIRD_PARTY.md +41 -0
- mea8000_encoder-0.1.0/mea8000/__init__.py +6 -0
- mea8000_encoder-0.1.0/mea8000/__main__.py +7 -0
- mea8000_encoder-0.1.0/mea8000/analysis.py +326 -0
- mea8000_encoder-0.1.0/mea8000/cli.py +287 -0
- mea8000_encoder-0.1.0/mea8000/codec.py +221 -0
- mea8000_encoder-0.1.0/mea8000/encoder.py +793 -0
- mea8000_encoder-0.1.0/mea8000/fastsim.py +260 -0
- mea8000_encoder-0.1.0/mea8000/filters.py +114 -0
- mea8000_encoder-0.1.0/mea8000/merge.py +129 -0
- mea8000_encoder-0.1.0/mea8000/profile.py +316 -0
- mea8000_encoder-0.1.0/mea8000/profiles/compact.toml +11 -0
- mea8000_encoder-0.1.0/mea8000/profiles/philips.toml +10 -0
- mea8000_encoder-0.1.0/mea8000/profiles/thomson.toml +21 -0
- mea8000_encoder-0.1.0/mea8000/report.py +254 -0
- mea8000_encoder-0.1.0/mea8000/sim.py +427 -0
- mea8000_encoder-0.1.0/mea8000/tables.py +34 -0
- mea8000_encoder-0.1.0/mea8000/tuning.py +96 -0
- mea8000_encoder-0.1.0/mea8000/wav.py +83 -0
- mea8000_encoder-0.1.0/mea8000_encoder.egg-info/PKG-INFO +142 -0
- mea8000_encoder-0.1.0/mea8000_encoder.egg-info/SOURCES.txt +29 -0
- mea8000_encoder-0.1.0/mea8000_encoder.egg-info/dependency_links.txt +1 -0
- mea8000_encoder-0.1.0/mea8000_encoder.egg-info/entry_points.txt +2 -0
- mea8000_encoder-0.1.0/mea8000_encoder.egg-info/requires.txt +8 -0
- mea8000_encoder-0.1.0/mea8000_encoder.egg-info/top_level.txt +1 -0
- mea8000_encoder-0.1.0/pyproject.toml +44 -0
- 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,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,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
|