openpronounce 0.2.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.
- openpronounce-0.2.0/LICENSE +21 -0
- openpronounce-0.2.0/PKG-INFO +235 -0
- openpronounce-0.2.0/README.md +187 -0
- openpronounce-0.2.0/openpronounce/__init__.py +35 -0
- openpronounce-0.2.0/openpronounce/audio.py +75 -0
- openpronounce-0.2.0/openpronounce/cli.py +48 -0
- openpronounce-0.2.0/openpronounce/phones.py +224 -0
- openpronounce-0.2.0/openpronounce/speech.py +420 -0
- openpronounce-0.2.0/openpronounce.egg-info/PKG-INFO +235 -0
- openpronounce-0.2.0/openpronounce.egg-info/SOURCES.txt +17 -0
- openpronounce-0.2.0/openpronounce.egg-info/dependency_links.txt +1 -0
- openpronounce-0.2.0/openpronounce.egg-info/entry_points.txt +2 -0
- openpronounce-0.2.0/openpronounce.egg-info/requires.txt +23 -0
- openpronounce-0.2.0/openpronounce.egg-info/top_level.txt +1 -0
- openpronounce-0.2.0/pyproject.toml +77 -0
- openpronounce-0.2.0/setup.cfg +4 -0
- openpronounce-0.2.0/tests/test_phones.py +76 -0
- openpronounce-0.2.0/tests/test_server.py +39 -0
- openpronounce-0.2.0/tests/test_speech.py +255 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jean-François Lépine <http://blog.lepine.pro>
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openpronounce
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Open-source, phoneme-level English pronunciation assessment (Wav2Vec2 + DTW). Score a recording, list mispronounced words, get prosody curves.
|
|
5
|
+
Author: Jean-François Lépine
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Halleck45/OpenPronounce
|
|
8
|
+
Project-URL: Repository, https://github.com/Halleck45/OpenPronounce
|
|
9
|
+
Project-URL: Issues, https://github.com/Halleck45/OpenPronounce/issues
|
|
10
|
+
Project-URL: Blog post, https://blog.lepine.pro/en/ai-wav2vec-pronunciation-vectorization/
|
|
11
|
+
Keywords: pronunciation,pronunciation-assessment,speech,phonemes,wav2vec2,CAPT,language-learning,asr,prosody,visemes
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Education
|
|
21
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: torch>=2.0
|
|
27
|
+
Requires-Dist: transformers>=4.30
|
|
28
|
+
Requires-Dist: librosa>=0.10
|
|
29
|
+
Requires-Dist: soundfile>=0.12
|
|
30
|
+
Requires-Dist: numpy>=1.24
|
|
31
|
+
Requires-Dist: scipy>=1.10
|
|
32
|
+
Requires-Dist: scikit-learn>=1.2
|
|
33
|
+
Requires-Dist: fastdtw>=0.3
|
|
34
|
+
Requires-Dist: phonemizer>=3.2
|
|
35
|
+
Requires-Dist: Levenshtein>=0.20
|
|
36
|
+
Requires-Dist: gTTS>=2.3
|
|
37
|
+
Provides-Extra: app
|
|
38
|
+
Requires-Dist: fastapi>=0.100; extra == "app"
|
|
39
|
+
Requires-Dist: uvicorn[standard]>=0.23; extra == "app"
|
|
40
|
+
Requires-Dist: python-multipart>=0.0.6; extra == "app"
|
|
41
|
+
Requires-Dist: jinja2>=3.1; extra == "app"
|
|
42
|
+
Requires-Dist: streamlit>=1.30; extra == "app"
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
46
|
+
Requires-Dist: httpx>=0.24; extra == "dev"
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
|
|
49
|
+
<h1 align="center">OpenPronounce</h1>
|
|
50
|
+
|
|
51
|
+
<p align="center">
|
|
52
|
+
<b>Open-source, phoneme-level English pronunciation assessment.</b><br>
|
|
53
|
+
Give it a recording and the sentence that was supposed to be said. Get a score, the mispronounced words with expected vs. heard phonemes (IPA), the transcription and the prosody curves. Runs on your machine, on CPU.
|
|
54
|
+
</p>
|
|
55
|
+
|
|
56
|
+
<p align="center">
|
|
57
|
+
<a href="https://colab.research.google.com/github/Halleck45/OpenPronounce/blob/main/OpenPronounce-demo.ipynb"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open in Colab"></a>
|
|
58
|
+
<a href="https://github.com/Halleck45/OpenPronounce/actions/workflows/tests.yml"><img src="https://github.com/Halleck45/OpenPronounce/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
|
|
59
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
60
|
+
<a href="https://github.com/sponsors/Halleck45"><img src="https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86" alt="Sponsor"></a>
|
|
61
|
+
</p>
|
|
62
|
+
|
|
63
|
+
<p align="center">
|
|
64
|
+
<img src="./docs/open-pronounce-preview.png" alt="OpenPronounce web application: score, mispronounced words, phoneme and prosody charts" width="720">
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
$ openpronounce recording.wav "Hello, how are you?"
|
|
69
|
+
Score : 55.74/100
|
|
70
|
+
Transcription: HELL NO WHO ARE YOU
|
|
71
|
+
Heard phones : /h ɛ l n oʊ h u ɑɹ j u/
|
|
72
|
+
Mispronounced:
|
|
73
|
+
- hello: expected /həloʊ/, heard /hɛlnoʊ/
|
|
74
|
+
- how: expected /haʊ/, heard /hu/
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Commercial APIs (Azure Speech *Pronunciation Assessment*, SpeechAce, ELSA...) do this behind a paywall and a network call. OpenPronounce is the self-hosted, MIT-licensed building block for language-learning apps, EdTech products and research: no API key, no per-minute billing, your learners' voices stay on your servers.
|
|
78
|
+
|
|
79
|
+
## What you get
|
|
80
|
+
|
|
81
|
+
For each recording, a JSON-serializable dict:
|
|
82
|
+
|
|
83
|
+
| Field | Meaning |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `score` | 0-100 overall pronunciation score |
|
|
86
|
+
| `transcribe` | what the model actually heard (Wav2Vec2 CTC) |
|
|
87
|
+
| `differences.errors[]` | one entry per mispronounced or missing word: `word`, `expected` (IPA), `actual` (IPA, what was really heard), `position` |
|
|
88
|
+
| `differences.heard_phones`, `differences.expected_phones` | the phones recognized in the audio, and the phones expected for each word |
|
|
89
|
+
| `differences.words_with_errors` | the words to work on |
|
|
90
|
+
| `differences.phoneme_error_rate`, `differences.word_error_rate` | edited phonemes / expected phonemes, edited words / expected words |
|
|
91
|
+
| `differences.expected_phonemes`, `differences.transcribed_phonemes` | full phoneme sequences |
|
|
92
|
+
| `differences.expected_vector`, `differences.transcribed_vector` | DTW-aligned phoneme traces, ready to plot |
|
|
93
|
+
| `acoustic_distance` | mean per-frame DTW distance between the learner's Wav2Vec2 embeddings and a synthetic reference |
|
|
94
|
+
| `prosody.f0`, `prosody.energy` | pitch and loudness contours |
|
|
95
|
+
|
|
96
|
+
## Quickstart
|
|
97
|
+
|
|
98
|
+
### Install
|
|
99
|
+
|
|
100
|
+
Requires Python 3.10+, `ffmpeg` and `espeak-ng` on the system (`apt install ffmpeg espeak-ng`, `brew install ffmpeg espeak-ng`).
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install torch --index-url https://download.pytorch.org/whl/cpu # CPU wheels, much smaller
|
|
104
|
+
pip install git+https://github.com/Halleck45/OpenPronounce.git
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Two Wav2Vec2 checkpoints (~1.2 GB each) are downloaded from the Hugging Face Hub on first use: `facebook/wav2vec2-large-960h` (words) and `facebook/wav2vec2-lv-60-espeak-cv-ft` (phones). Set `OPENPRONOUNCE_PHONEME_MODEL=off` to skip the second one; word errors are then inferred from the transcription, which is less precise.
|
|
108
|
+
|
|
109
|
+
### Command line
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
openpronounce recording.wav "Hello, I am a developer"
|
|
113
|
+
openpronounce recording.mp3 "Hello, I am a developer" --json --no-prosody # machine-readable
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Python
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from openpronounce import load_audio, compare_audio_with_text
|
|
120
|
+
|
|
121
|
+
sound = load_audio("recording.wav") # any format ffmpeg reads, resampled to 16 kHz mono
|
|
122
|
+
result = compare_audio_with_text(sound, "Hello, I am a developer")
|
|
123
|
+
|
|
124
|
+
print(result["score"]) # 97.36
|
|
125
|
+
for err in result["differences"]["errors"]:
|
|
126
|
+
print(err["word"], err["expected"], "->", err["actual"] or "(missing)")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Lower-level building blocks are exposed too: `transcribe(sound)`, `get_phonemes(text)`, `compare_transcriptions(heard_text, expected_text)`.
|
|
130
|
+
|
|
131
|
+
### Docker
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
docker build -t openpronounce .
|
|
135
|
+
docker run -p 8000:8000 openpronounce
|
|
136
|
+
# open http://localhost:8000
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Web application (FastAPI)
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pip install "openpronounce[app] @ git+https://github.com/Halleck45/OpenPronounce.git"
|
|
143
|
+
git clone https://github.com/Halleck45/OpenPronounce.git && cd OpenPronounce
|
|
144
|
+
uvicorn server:app --host 0.0.0.0 --port 8000
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The UI records from the microphone, scores the sentence, animates a mouth (visemes) and plots the phoneme traces and prosody. Browsers only allow microphone access on `https://` or `localhost`.
|
|
148
|
+
|
|
149
|
+
| Endpoint | Body (multipart form) | Returns |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `POST /pronunciation` | `file`, `expected_text` | full analysis (see above) |
|
|
152
|
+
| `POST /speech2text` | `file` | `{"transcript": ...}` |
|
|
153
|
+
| `POST /phonemes` | `text` | `{"phonemes": [...], "words": [...]}` |
|
|
154
|
+
| `POST /tts` | `text` | reference pronunciation, 16 kHz wav |
|
|
155
|
+
| `GET /health` | | `{"status": "ok"}` |
|
|
156
|
+
|
|
157
|
+
Interactive docs at `/docs` (Swagger UI).
|
|
158
|
+
|
|
159
|
+
### Streamlit
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
streamlit run streamlit_app.py
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Notebook
|
|
166
|
+
|
|
167
|
+
[Open in Colab](https://colab.research.google.com/github/Halleck45/OpenPronounce/blob/main/OpenPronounce-demo.ipynb): load a sample, score it, print the phoneme errors, plot the prosody, then try your own recording. No local setup.
|
|
168
|
+
|
|
169
|
+
## How it works
|
|
170
|
+
|
|
171
|
+
1. **Phones**: a Wav2Vec2 model fine-tuned on espeak labels (`wav2vec2-lv-60-espeak-cv-ft`) recognizes the phones actually said, straight from the audio. No word-level language model gets a chance to "correct" the learner.
|
|
172
|
+
2. **Expected phones**: the sentence is phonemized with espeak-ng (IPA), word by word. Both sequences are normalized (length marks dropped, reduced vowels merged, cot-caught merger, a few function words with alternate pronunciations).
|
|
173
|
+
3. **Alignment**: expected and heard phones are aligned with edit-distance opcodes; each word is compared with the phones it aligned to and reported when half of them (or 3 or more) are wrong. Thresholds: `phones.PHONE_ERROR_THRESHOLD`, `phones.PHONE_ERROR_MIN_EDITS`.
|
|
174
|
+
4. **Words**: the audio is also transcribed with `wav2vec2-large-960h` for the transcription and the word error rate.
|
|
175
|
+
5. **Acoustics**: the sentence is synthesized (gTTS), both recordings are encoded with Wav2Vec2 and aligned with DTW; the mean per-frame distance is the `acoustic_distance`.
|
|
176
|
+
6. **Prosody**: F0 (pYIN) and RMS energy contours.
|
|
177
|
+
|
|
178
|
+
The approach is described in [this blog post](https://blog.lepine.pro/en/ai-wav2vec-pronunciation-vectorization/).
|
|
179
|
+
|
|
180
|
+
### The score
|
|
181
|
+
|
|
182
|
+
`score = 0.2 × acoustic + 0.5 × (1 − phoneme error rate) + 0.3 × (1 − word error rate)`, each term clipped to [0, 100].
|
|
183
|
+
The acoustic term maps the mean DTW distance linearly from 5 (100) to 15 (0); these bounds come from the bundled samples (`assets/`) and are exposed as `speech.ACOUSTIC_DISTANCE_GOOD` / `speech.ACOUSTIC_DISTANCE_BAD` if you want to recalibrate on your own data. All three terms are length-independent, so a long paragraph and a two-word sentence are scored on the same scale.
|
|
184
|
+
|
|
185
|
+
## Visemes
|
|
186
|
+
|
|
187
|
+
The web UI ships a small [phoneme-to-viseme](static/viseme.js) mapping for English (HumanBeanCMU39 mouth shapes), enough to animate a talking mouth from the phoneme list:
|
|
188
|
+
|
|
189
|
+
```javascript
|
|
190
|
+
import { Viseme } from "/static/viseme.js";
|
|
191
|
+
const viseme = new Viseme(document.getElementById("mouth"));
|
|
192
|
+
viseme.play(["həloʊ", "huː", "ɑːɹ", "juː"]);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Limitations
|
|
196
|
+
|
|
197
|
+
- English only for now (`en-us` phonemization, English Wav2Vec2). Swapping the model and the espeak language is the path to other languages.
|
|
198
|
+
- The reference voice comes from gTTS, so the first analysis of a given sentence needs network access; references are cached afterwards.
|
|
199
|
+
- Wav2Vec2 was trained on native read speech (LibriSpeech). Very strong accents, children's voices and noisy recordings degrade the transcription, and therefore the feedback.
|
|
200
|
+
- The phone recognizer itself has an error rate (about 10 % of phones on a clean native reading of the bundled Harvard sentences); expect an occasional false alarm on short words. This is a heuristic assessment, not a Goodness-of-Pronunciation model trained on annotated L2 speech.
|
|
201
|
+
|
|
202
|
+
## Roadmap
|
|
203
|
+
|
|
204
|
+
Contributions welcome on any of these:
|
|
205
|
+
|
|
206
|
+
- [ ] Publish on PyPI (`pip install openpronounce`)
|
|
207
|
+
- [ ] Hosted demo (Hugging Face Space)
|
|
208
|
+
- [ ] Offline TTS reference (piper / Kokoro) instead of gTTS
|
|
209
|
+
- [ ] Per-phone confidence (CTC posteriors) to grade errors instead of a yes/no per word
|
|
210
|
+
- [ ] Other languages
|
|
211
|
+
- [ ] Benchmark on a public L2 dataset (speechocean762) to calibrate the score
|
|
212
|
+
- [ ] GPU support in the Docker image
|
|
213
|
+
|
|
214
|
+
## Contributing
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
git clone https://github.com/Halleck45/OpenPronounce.git && cd OpenPronounce
|
|
218
|
+
python -m venv .venv && source .venv/bin/activate
|
|
219
|
+
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
|
220
|
+
pip install -e ".[app,dev]"
|
|
221
|
+
pytest
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Tests do not need the network nor the model weights (model calls are mocked); espeak-ng must be installed.
|
|
225
|
+
|
|
226
|
+
## References
|
|
227
|
+
|
|
228
|
+
- [Vectorisation of sounds for pronunciation](https://blog.lepine.pro/en/ai-wav2vec-pronunciation-vectorization/) (blog post about this project)
|
|
229
|
+
- [wav2vec 2.0](https://ai.meta.com/research/impact/wav2vec/), Baevski et al., 2020
|
|
230
|
+
- [Azure Speech visemes](https://learn.microsoft.com/azure/ai-services/speech-service/how-to-speech-synthesis-viseme) and [SSML phonetic sets](https://learn.microsoft.com/azure/ai-services/speech-service/speech-ssml-phonetic-sets)
|
|
231
|
+
- Mouth images: HumanBeanCMU39 viseme set
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<h1 align="center">OpenPronounce</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<b>Open-source, phoneme-level English pronunciation assessment.</b><br>
|
|
5
|
+
Give it a recording and the sentence that was supposed to be said. Get a score, the mispronounced words with expected vs. heard phonemes (IPA), the transcription and the prosody curves. Runs on your machine, on CPU.
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://colab.research.google.com/github/Halleck45/OpenPronounce/blob/main/OpenPronounce-demo.ipynb"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open in Colab"></a>
|
|
10
|
+
<a href="https://github.com/Halleck45/OpenPronounce/actions/workflows/tests.yml"><img src="https://github.com/Halleck45/OpenPronounce/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
|
|
11
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
12
|
+
<a href="https://github.com/sponsors/Halleck45"><img src="https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86" alt="Sponsor"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="./docs/open-pronounce-preview.png" alt="OpenPronounce web application: score, mispronounced words, phoneme and prosody charts" width="720">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
```console
|
|
20
|
+
$ openpronounce recording.wav "Hello, how are you?"
|
|
21
|
+
Score : 55.74/100
|
|
22
|
+
Transcription: HELL NO WHO ARE YOU
|
|
23
|
+
Heard phones : /h ɛ l n oʊ h u ɑɹ j u/
|
|
24
|
+
Mispronounced:
|
|
25
|
+
- hello: expected /həloʊ/, heard /hɛlnoʊ/
|
|
26
|
+
- how: expected /haʊ/, heard /hu/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Commercial APIs (Azure Speech *Pronunciation Assessment*, SpeechAce, ELSA...) do this behind a paywall and a network call. OpenPronounce is the self-hosted, MIT-licensed building block for language-learning apps, EdTech products and research: no API key, no per-minute billing, your learners' voices stay on your servers.
|
|
30
|
+
|
|
31
|
+
## What you get
|
|
32
|
+
|
|
33
|
+
For each recording, a JSON-serializable dict:
|
|
34
|
+
|
|
35
|
+
| Field | Meaning |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `score` | 0-100 overall pronunciation score |
|
|
38
|
+
| `transcribe` | what the model actually heard (Wav2Vec2 CTC) |
|
|
39
|
+
| `differences.errors[]` | one entry per mispronounced or missing word: `word`, `expected` (IPA), `actual` (IPA, what was really heard), `position` |
|
|
40
|
+
| `differences.heard_phones`, `differences.expected_phones` | the phones recognized in the audio, and the phones expected for each word |
|
|
41
|
+
| `differences.words_with_errors` | the words to work on |
|
|
42
|
+
| `differences.phoneme_error_rate`, `differences.word_error_rate` | edited phonemes / expected phonemes, edited words / expected words |
|
|
43
|
+
| `differences.expected_phonemes`, `differences.transcribed_phonemes` | full phoneme sequences |
|
|
44
|
+
| `differences.expected_vector`, `differences.transcribed_vector` | DTW-aligned phoneme traces, ready to plot |
|
|
45
|
+
| `acoustic_distance` | mean per-frame DTW distance between the learner's Wav2Vec2 embeddings and a synthetic reference |
|
|
46
|
+
| `prosody.f0`, `prosody.energy` | pitch and loudness contours |
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
### Install
|
|
51
|
+
|
|
52
|
+
Requires Python 3.10+, `ffmpeg` and `espeak-ng` on the system (`apt install ffmpeg espeak-ng`, `brew install ffmpeg espeak-ng`).
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install torch --index-url https://download.pytorch.org/whl/cpu # CPU wheels, much smaller
|
|
56
|
+
pip install git+https://github.com/Halleck45/OpenPronounce.git
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Two Wav2Vec2 checkpoints (~1.2 GB each) are downloaded from the Hugging Face Hub on first use: `facebook/wav2vec2-large-960h` (words) and `facebook/wav2vec2-lv-60-espeak-cv-ft` (phones). Set `OPENPRONOUNCE_PHONEME_MODEL=off` to skip the second one; word errors are then inferred from the transcription, which is less precise.
|
|
60
|
+
|
|
61
|
+
### Command line
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
openpronounce recording.wav "Hello, I am a developer"
|
|
65
|
+
openpronounce recording.mp3 "Hello, I am a developer" --json --no-prosody # machine-readable
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Python
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from openpronounce import load_audio, compare_audio_with_text
|
|
72
|
+
|
|
73
|
+
sound = load_audio("recording.wav") # any format ffmpeg reads, resampled to 16 kHz mono
|
|
74
|
+
result = compare_audio_with_text(sound, "Hello, I am a developer")
|
|
75
|
+
|
|
76
|
+
print(result["score"]) # 97.36
|
|
77
|
+
for err in result["differences"]["errors"]:
|
|
78
|
+
print(err["word"], err["expected"], "->", err["actual"] or "(missing)")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Lower-level building blocks are exposed too: `transcribe(sound)`, `get_phonemes(text)`, `compare_transcriptions(heard_text, expected_text)`.
|
|
82
|
+
|
|
83
|
+
### Docker
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
docker build -t openpronounce .
|
|
87
|
+
docker run -p 8000:8000 openpronounce
|
|
88
|
+
# open http://localhost:8000
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Web application (FastAPI)
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install "openpronounce[app] @ git+https://github.com/Halleck45/OpenPronounce.git"
|
|
95
|
+
git clone https://github.com/Halleck45/OpenPronounce.git && cd OpenPronounce
|
|
96
|
+
uvicorn server:app --host 0.0.0.0 --port 8000
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The UI records from the microphone, scores the sentence, animates a mouth (visemes) and plots the phoneme traces and prosody. Browsers only allow microphone access on `https://` or `localhost`.
|
|
100
|
+
|
|
101
|
+
| Endpoint | Body (multipart form) | Returns |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `POST /pronunciation` | `file`, `expected_text` | full analysis (see above) |
|
|
104
|
+
| `POST /speech2text` | `file` | `{"transcript": ...}` |
|
|
105
|
+
| `POST /phonemes` | `text` | `{"phonemes": [...], "words": [...]}` |
|
|
106
|
+
| `POST /tts` | `text` | reference pronunciation, 16 kHz wav |
|
|
107
|
+
| `GET /health` | | `{"status": "ok"}` |
|
|
108
|
+
|
|
109
|
+
Interactive docs at `/docs` (Swagger UI).
|
|
110
|
+
|
|
111
|
+
### Streamlit
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
streamlit run streamlit_app.py
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Notebook
|
|
118
|
+
|
|
119
|
+
[Open in Colab](https://colab.research.google.com/github/Halleck45/OpenPronounce/blob/main/OpenPronounce-demo.ipynb): load a sample, score it, print the phoneme errors, plot the prosody, then try your own recording. No local setup.
|
|
120
|
+
|
|
121
|
+
## How it works
|
|
122
|
+
|
|
123
|
+
1. **Phones**: a Wav2Vec2 model fine-tuned on espeak labels (`wav2vec2-lv-60-espeak-cv-ft`) recognizes the phones actually said, straight from the audio. No word-level language model gets a chance to "correct" the learner.
|
|
124
|
+
2. **Expected phones**: the sentence is phonemized with espeak-ng (IPA), word by word. Both sequences are normalized (length marks dropped, reduced vowels merged, cot-caught merger, a few function words with alternate pronunciations).
|
|
125
|
+
3. **Alignment**: expected and heard phones are aligned with edit-distance opcodes; each word is compared with the phones it aligned to and reported when half of them (or 3 or more) are wrong. Thresholds: `phones.PHONE_ERROR_THRESHOLD`, `phones.PHONE_ERROR_MIN_EDITS`.
|
|
126
|
+
4. **Words**: the audio is also transcribed with `wav2vec2-large-960h` for the transcription and the word error rate.
|
|
127
|
+
5. **Acoustics**: the sentence is synthesized (gTTS), both recordings are encoded with Wav2Vec2 and aligned with DTW; the mean per-frame distance is the `acoustic_distance`.
|
|
128
|
+
6. **Prosody**: F0 (pYIN) and RMS energy contours.
|
|
129
|
+
|
|
130
|
+
The approach is described in [this blog post](https://blog.lepine.pro/en/ai-wav2vec-pronunciation-vectorization/).
|
|
131
|
+
|
|
132
|
+
### The score
|
|
133
|
+
|
|
134
|
+
`score = 0.2 × acoustic + 0.5 × (1 − phoneme error rate) + 0.3 × (1 − word error rate)`, each term clipped to [0, 100].
|
|
135
|
+
The acoustic term maps the mean DTW distance linearly from 5 (100) to 15 (0); these bounds come from the bundled samples (`assets/`) and are exposed as `speech.ACOUSTIC_DISTANCE_GOOD` / `speech.ACOUSTIC_DISTANCE_BAD` if you want to recalibrate on your own data. All three terms are length-independent, so a long paragraph and a two-word sentence are scored on the same scale.
|
|
136
|
+
|
|
137
|
+
## Visemes
|
|
138
|
+
|
|
139
|
+
The web UI ships a small [phoneme-to-viseme](static/viseme.js) mapping for English (HumanBeanCMU39 mouth shapes), enough to animate a talking mouth from the phoneme list:
|
|
140
|
+
|
|
141
|
+
```javascript
|
|
142
|
+
import { Viseme } from "/static/viseme.js";
|
|
143
|
+
const viseme = new Viseme(document.getElementById("mouth"));
|
|
144
|
+
viseme.play(["həloʊ", "huː", "ɑːɹ", "juː"]);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Limitations
|
|
148
|
+
|
|
149
|
+
- English only for now (`en-us` phonemization, English Wav2Vec2). Swapping the model and the espeak language is the path to other languages.
|
|
150
|
+
- The reference voice comes from gTTS, so the first analysis of a given sentence needs network access; references are cached afterwards.
|
|
151
|
+
- Wav2Vec2 was trained on native read speech (LibriSpeech). Very strong accents, children's voices and noisy recordings degrade the transcription, and therefore the feedback.
|
|
152
|
+
- The phone recognizer itself has an error rate (about 10 % of phones on a clean native reading of the bundled Harvard sentences); expect an occasional false alarm on short words. This is a heuristic assessment, not a Goodness-of-Pronunciation model trained on annotated L2 speech.
|
|
153
|
+
|
|
154
|
+
## Roadmap
|
|
155
|
+
|
|
156
|
+
Contributions welcome on any of these:
|
|
157
|
+
|
|
158
|
+
- [ ] Publish on PyPI (`pip install openpronounce`)
|
|
159
|
+
- [ ] Hosted demo (Hugging Face Space)
|
|
160
|
+
- [ ] Offline TTS reference (piper / Kokoro) instead of gTTS
|
|
161
|
+
- [ ] Per-phone confidence (CTC posteriors) to grade errors instead of a yes/no per word
|
|
162
|
+
- [ ] Other languages
|
|
163
|
+
- [ ] Benchmark on a public L2 dataset (speechocean762) to calibrate the score
|
|
164
|
+
- [ ] GPU support in the Docker image
|
|
165
|
+
|
|
166
|
+
## Contributing
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
git clone https://github.com/Halleck45/OpenPronounce.git && cd OpenPronounce
|
|
170
|
+
python -m venv .venv && source .venv/bin/activate
|
|
171
|
+
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
|
172
|
+
pip install -e ".[app,dev]"
|
|
173
|
+
pytest
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Tests do not need the network nor the model weights (model calls are mocked); espeak-ng must be installed.
|
|
177
|
+
|
|
178
|
+
## References
|
|
179
|
+
|
|
180
|
+
- [Vectorisation of sounds for pronunciation](https://blog.lepine.pro/en/ai-wav2vec-pronunciation-vectorization/) (blog post about this project)
|
|
181
|
+
- [wav2vec 2.0](https://ai.meta.com/research/impact/wav2vec/), Baevski et al., 2020
|
|
182
|
+
- [Azure Speech visemes](https://learn.microsoft.com/azure/ai-services/speech-service/how-to-speech-synthesis-viseme) and [SSML phonetic sets](https://learn.microsoft.com/azure/ai-services/speech-service/speech-ssml-phonetic-sets)
|
|
183
|
+
- Mouth images: HumanBeanCMU39 viseme set
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""OpenPronounce: open-source, phoneme-level English pronunciation assessment.
|
|
2
|
+
|
|
3
|
+
Typical usage::
|
|
4
|
+
|
|
5
|
+
from openpronounce import load_audio, compare_audio_with_text
|
|
6
|
+
|
|
7
|
+
sound = load_audio("recording.wav")
|
|
8
|
+
result = compare_audio_with_text(sound, "Hello, I am a developer")
|
|
9
|
+
print(result["score"], result["differences"]["errors"])
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .audio import load as load_audio, text2speech
|
|
13
|
+
from .phones import compare_phones, transcribe_phones
|
|
14
|
+
from .speech import (
|
|
15
|
+
compare_audio_with_text,
|
|
16
|
+
compare_transcriptions,
|
|
17
|
+
get_phonemes,
|
|
18
|
+
get_phonemes_with_word_mapping,
|
|
19
|
+
transcribe,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.2.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"__version__",
|
|
26
|
+
"load_audio",
|
|
27
|
+
"text2speech",
|
|
28
|
+
"compare_audio_with_text",
|
|
29
|
+
"compare_transcriptions",
|
|
30
|
+
"compare_phones",
|
|
31
|
+
"transcribe_phones",
|
|
32
|
+
"get_phonemes",
|
|
33
|
+
"get_phonemes_with_word_mapping",
|
|
34
|
+
"transcribe",
|
|
35
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Audio loading, conversion and reference-speech generation."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
import librosa
|
|
9
|
+
import soundfile as sf
|
|
10
|
+
from gtts import gTTS
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
TARGET_SR = 16000
|
|
15
|
+
|
|
16
|
+
CACHE_DIR = os.environ.get(
|
|
17
|
+
"OPENPRONOUNCE_CACHE_DIR",
|
|
18
|
+
os.path.join(tempfile.gettempdir(), "openpronounce"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load(file_path, sr=TARGET_SR):
|
|
23
|
+
"""Load any audio file (wav, mp3, flac, ogg, webm, m4a...) as a mono float32 waveform at 16 kHz."""
|
|
24
|
+
waveform, _ = librosa.load(file_path, sr=sr, mono=True)
|
|
25
|
+
return waveform
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def webm2wav(file_path):
|
|
29
|
+
"""Convert a browser-recorded file (webm/ogg/wav/...) to a 16 kHz mono ``*.16k.wav`` file next to it.
|
|
30
|
+
|
|
31
|
+
Uses librosa (soundfile, then ffmpeg through audioread) so that ffmpeg is only
|
|
32
|
+
needed for formats libsndfile cannot read.
|
|
33
|
+
"""
|
|
34
|
+
output_path = os.path.splitext(file_path)[0] + ".16k.wav"
|
|
35
|
+
try:
|
|
36
|
+
waveform = load(file_path)
|
|
37
|
+
except Exception as e: # noqa: BLE001
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
f"Unable to decode {file_path!r} ({e}). Make sure ffmpeg is installed."
|
|
40
|
+
) from e
|
|
41
|
+
sf.write(output_path, waveform, TARGET_SR)
|
|
42
|
+
return output_path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Backward-compatible alias (the original name was a typo)
|
|
46
|
+
webp2wav = webm2wav
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def text2speech(text, lang="en", filename=None, target_sr=TARGET_SR):
|
|
50
|
+
"""Generate a reference pronunciation of ``text`` as a 16 kHz mono wav file and return its path.
|
|
51
|
+
|
|
52
|
+
Uses gTTS (Google Translate TTS), so an internet connection is required the first
|
|
53
|
+
time a given text is requested. Results are cached in ``CACHE_DIR`` keyed by
|
|
54
|
+
``(lang, text)`` so that repeated comparisons against the same sentence are free.
|
|
55
|
+
"""
|
|
56
|
+
if filename is None:
|
|
57
|
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
58
|
+
key = hashlib.sha1(f"{lang}\x00{target_sr}\x00{text}".encode("utf-8")).hexdigest()
|
|
59
|
+
filename = os.path.join(CACHE_DIR, f"tts-{key}.wav")
|
|
60
|
+
if os.path.exists(filename):
|
|
61
|
+
return filename
|
|
62
|
+
|
|
63
|
+
fd, mp3_path = tempfile.mkstemp(suffix=".mp3", prefix="openpronounce-tts-")
|
|
64
|
+
os.close(fd)
|
|
65
|
+
try:
|
|
66
|
+
gTTS(text=text, lang=lang, slow=False).save(mp3_path)
|
|
67
|
+
waveform = load(mp3_path, sr=target_sr)
|
|
68
|
+
finally:
|
|
69
|
+
try:
|
|
70
|
+
os.remove(mp3_path)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
sf.write(filename, waveform, target_sr)
|
|
75
|
+
return filename
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Command-line entry point: ``openpronounce <audio> "<expected text>"``."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None):
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="openpronounce",
|
|
11
|
+
description="Score the pronunciation of an English recording against the sentence it should contain.",
|
|
12
|
+
)
|
|
13
|
+
parser.add_argument("audio", help="path to a wav/mp3/flac/ogg/webm recording")
|
|
14
|
+
parser.add_argument("text", help="the sentence the speaker was supposed to say")
|
|
15
|
+
parser.add_argument("--json", action="store_true", help="print the full JSON result instead of a summary")
|
|
16
|
+
parser.add_argument("--no-prosody", action="store_true", help="omit prosody contours from the JSON output")
|
|
17
|
+
args = parser.parse_args(argv)
|
|
18
|
+
|
|
19
|
+
from . import audio, speech
|
|
20
|
+
|
|
21
|
+
sound = audio.load(args.audio)
|
|
22
|
+
result = speech.compare_audio_with_text(sound, args.text)
|
|
23
|
+
|
|
24
|
+
if args.no_prosody:
|
|
25
|
+
result.pop("prosody", None)
|
|
26
|
+
|
|
27
|
+
if args.json:
|
|
28
|
+
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
|
|
29
|
+
sys.stdout.write("\n")
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
print(f"Score : {result['score']}/100")
|
|
33
|
+
print(f"Transcription: {result['transcribe']}")
|
|
34
|
+
if "heard_phones" in result["differences"]:
|
|
35
|
+
print(f"Heard phones : /{' '.join(result['differences']['heard_phones'])}/")
|
|
36
|
+
errors = result["differences"]["errors"]
|
|
37
|
+
if errors:
|
|
38
|
+
print("Mispronounced:")
|
|
39
|
+
for err in errors:
|
|
40
|
+
actual = f"/{err['actual']}/" if err["actual"] else "(missing)"
|
|
41
|
+
print(f" - {err['word']}: expected /{err['expected']}/, heard {actual}")
|
|
42
|
+
else:
|
|
43
|
+
print("No mispronounced word detected.")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
sys.exit(main())
|