Dynamic Accent Duplex 0.2.0: Dynamic ASR real as default ASR (omniASR fallback)
Browse files- .gitattributes +4 -0
- LICENSE +202 -0
- README.md +173 -0
- code/asr_server.py +95 -0
- code/eval/analyze.py +29 -0
- code/eval/asr_omni.py +27 -0
- code/eval/asr_score.py +66 -0
- code/eval/asr_via_server.py +8 -0
- code/eval/asr_whisper.py +17 -0
- code/eval/build_asr_eval.py +48 -0
- code/eval/llm_eval.py +84 -0
- code/eval/llm_prompts.json +18 -0
- code/eval/make_report.py +28 -0
- code/eval/ws_test.py +34 -0
- code/llm.py +62 -0
- code/mix.py +136 -0
- code/offline.py +124 -0
- code/respond.py +35 -0
- code/scenarios.json +33 -0
- code/server.py +132 -0
- code/static/index.html +46 -0
- code/tagger.py +348 -0
- code/tts_engine.py +93 -0
- code/turn.py +68 -0
- code/validator.py +79 -0
- config.json +98 -0
- launch.sh +16 -0
- requirements.txt +13 -0
- samples/01_igbo_greetings_english.json +354 -0
- samples/01_igbo_greetings_english.mp3 +3 -0
- samples/03_pidgin_bargein.json +257 -0
- samples/03_pidgin_bargein.mp3 +3 -0
- samples/04_hausa_greetings_english.json +296 -0
- samples/04_hausa_greetings_english.mp3 +3 -0
- samples/05_igbo_heavy.json +259 -0
- samples/05_igbo_heavy.mp3 +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
samples/01_igbo_greetings_english.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
samples/03_pidgin_bargein.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
samples/04_hausa_greetings_english.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
samples/05_igbo_heavy.mp3 filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Apache License
|
| 3 |
+
Version 2.0, January 2004
|
| 4 |
+
http://www.apache.org/licenses/
|
| 5 |
+
|
| 6 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 7 |
+
|
| 8 |
+
1. Definitions.
|
| 9 |
+
|
| 10 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 11 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 12 |
+
|
| 13 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 14 |
+
the copyright owner that is granting the License.
|
| 15 |
+
|
| 16 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 17 |
+
other entities that control, are controlled by, or are under common
|
| 18 |
+
control with that entity. For the purposes of this definition,
|
| 19 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 20 |
+
direction or management of such entity, whether by contract or
|
| 21 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 22 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 23 |
+
|
| 24 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 25 |
+
exercising permissions granted by this License.
|
| 26 |
+
|
| 27 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 28 |
+
including but not limited to software source code, documentation
|
| 29 |
+
source, and configuration files.
|
| 30 |
+
|
| 31 |
+
"Object" form shall mean any form resulting from mechanical
|
| 32 |
+
transformation or translation of a Source form, including but
|
| 33 |
+
not limited to compiled object code, generated documentation,
|
| 34 |
+
and conversions to other media types.
|
| 35 |
+
|
| 36 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 37 |
+
Object form, made available under the License, as indicated by a
|
| 38 |
+
copyright notice that is included in or attached to the work
|
| 39 |
+
(an example is provided in the Appendix below).
|
| 40 |
+
|
| 41 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 42 |
+
form, that is based on (or derived from) the Work and for which the
|
| 43 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 44 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 45 |
+
of this License, Derivative Works shall not include works that remain
|
| 46 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 47 |
+
the Work and Derivative Works thereof.
|
| 48 |
+
|
| 49 |
+
"Contribution" shall mean any work of authorship, including
|
| 50 |
+
the original version of the Work and any modifications or additions
|
| 51 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 52 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 53 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 54 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 55 |
+
means any form of electronic, verbal, or written communication sent
|
| 56 |
+
to the Licensor or its representatives, including but not limited to
|
| 57 |
+
communication on electronic mailing lists, source code control systems,
|
| 58 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 59 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 60 |
+
excluding communication that is conspicuously marked or otherwise
|
| 61 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 62 |
+
|
| 63 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 64 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 65 |
+
subsequently incorporated within the Work.
|
| 66 |
+
|
| 67 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 68 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 69 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 70 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 71 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 72 |
+
Work and such Derivative Works in Source or Object form.
|
| 73 |
+
|
| 74 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 75 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 76 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 77 |
+
(except as stated in this section) patent license to make, have made,
|
| 78 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 79 |
+
where such license applies only to those patent claims licensable
|
| 80 |
+
by such Contributor that are necessarily infringed by their
|
| 81 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 82 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 83 |
+
institute patent litigation against any entity (including a
|
| 84 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 85 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 86 |
+
or contributory patent infringement, then any patent licenses
|
| 87 |
+
granted to You under this License for that Work shall terminate
|
| 88 |
+
as of the date such litigation is filed.
|
| 89 |
+
|
| 90 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 91 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 92 |
+
modifications, and in Source or Object form, provided that You
|
| 93 |
+
meet the following conditions:
|
| 94 |
+
|
| 95 |
+
(a) You must give any other recipients of the Work or
|
| 96 |
+
Derivative Works a copy of this License; and
|
| 97 |
+
|
| 98 |
+
(b) You must cause any modified files to carry prominent notices
|
| 99 |
+
stating that You changed the files; and
|
| 100 |
+
|
| 101 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 102 |
+
that You distribute, all copyright, patent, trademark, and
|
| 103 |
+
attribution notices from the Source form of the Work,
|
| 104 |
+
excluding those notices that do not pertain to any part of
|
| 105 |
+
the Derivative Works; and
|
| 106 |
+
|
| 107 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 108 |
+
distribution, then any Derivative Works that You distribute must
|
| 109 |
+
include a readable copy of the attribution notices contained
|
| 110 |
+
within such NOTICE file, excluding those notices that do not
|
| 111 |
+
pertain to any part of the Derivative Works, in at least one
|
| 112 |
+
of the following places: within a NOTICE text file distributed
|
| 113 |
+
as part of the Derivative Works; within the Source form or
|
| 114 |
+
documentation, if provided along with the Derivative Works; or,
|
| 115 |
+
within a display generated by the Derivative Works, if and
|
| 116 |
+
wherever such third-party notices normally appear. The contents
|
| 117 |
+
of the NOTICE file are for informational purposes only and
|
| 118 |
+
do not modify the License. You may add Your own attribution
|
| 119 |
+
notices within Derivative Works that You distribute, alongside
|
| 120 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 121 |
+
that such additional attribution notices cannot be construed
|
| 122 |
+
as modifying the License.
|
| 123 |
+
|
| 124 |
+
You may add Your own copyright statement to Your modifications and
|
| 125 |
+
may provide additional or different license terms and conditions
|
| 126 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 127 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 128 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 129 |
+
the conditions stated in this License.
|
| 130 |
+
|
| 131 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 132 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 133 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 134 |
+
this License, without any additional terms or conditions.
|
| 135 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 136 |
+
the terms of any separate license agreement you may have executed
|
| 137 |
+
with Licensor regarding such Contributions.
|
| 138 |
+
|
| 139 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 140 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 141 |
+
except as required for reasonable and customary use in describing the
|
| 142 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 143 |
+
|
| 144 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 145 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 146 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 147 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 148 |
+
implied, including, without limitation, any warranties or conditions
|
| 149 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 150 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 151 |
+
appropriateness of using or redistributing the Work and assume any
|
| 152 |
+
risks associated with Your exercise of permissions under this License.
|
| 153 |
+
|
| 154 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 155 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 156 |
+
unless required by applicable law (such as deliberate and grossly
|
| 157 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 158 |
+
liable to You for damages, including any direct, indirect, special,
|
| 159 |
+
incidental, or consequential damages of any character arising as a
|
| 160 |
+
result of this License or out of the use or inability to use the
|
| 161 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 162 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 163 |
+
other commercial damages or losses), even if such Contributor
|
| 164 |
+
has been advised of the possibility of such damages.
|
| 165 |
+
|
| 166 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 167 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 168 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 169 |
+
or other liability obligations and/or rights consistent with this
|
| 170 |
+
License. However, in accepting such obligations, You may act only
|
| 171 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 172 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 173 |
+
defend, and hold each Contributor harmless for any liability
|
| 174 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 175 |
+
of your accepting any such warranty or additional liability.
|
| 176 |
+
|
| 177 |
+
END OF TERMS AND CONDITIONS
|
| 178 |
+
|
| 179 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 180 |
+
|
| 181 |
+
To apply the Apache License to your work, attach the following
|
| 182 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 183 |
+
replaced with your own identifying information. (Don't include
|
| 184 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 185 |
+
comment syntax for the file format. We also recommend that a
|
| 186 |
+
file or class name and description of purpose be included on the
|
| 187 |
+
same "printed page" as the copyright notice for easier
|
| 188 |
+
identification within third-party archives.
|
| 189 |
+
|
| 190 |
+
Copyright [yyyy] [name of copyright owner]
|
| 191 |
+
|
| 192 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 193 |
+
you may not use this file except in compliance with the License.
|
| 194 |
+
You may obtain a copy of the License at
|
| 195 |
+
|
| 196 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 197 |
+
|
| 198 |
+
Unless required by applicable law or agreed to in writing, software
|
| 199 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 200 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 201 |
+
See the License for the specific language governing permissions and
|
| 202 |
+
limitations under the License.
|
README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
library_name: pytorch
|
| 4 |
+
language:
|
| 5 |
+
- en
|
| 6 |
+
- pcm
|
| 7 |
+
- ig
|
| 8 |
+
- yo
|
| 9 |
+
- ha
|
| 10 |
+
tags:
|
| 11 |
+
- speech-to-speech
|
| 12 |
+
- full-duplex
|
| 13 |
+
- voice-agent
|
| 14 |
+
- code-switching
|
| 15 |
+
- nigerian-languages
|
| 16 |
+
- asr
|
| 17 |
+
- tts
|
| 18 |
+
- experimental
|
| 19 |
+
pipeline_tag: audio-to-audio
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
# Dynamic Accent Duplex (experimental)
|
| 23 |
+
|
| 24 |
+
**An experimental cascaded full-duplex voice agent for Nigerian code-switched speech** (English, Nigerian Pidgin, Igbo, Yoruba, Hausa) by Modular.
|
| 25 |
+
You talk however you naturally mix languages; it answers **in the same mix**. Mostly English with an Igbo greeting gets a reply in mostly English with an Igbo greeting. Pidgin gets Pidgin.
|
| 26 |
+
|
| 27 |
+
This is **not** a native speech-to-speech model (no Moshi-style joint model). It is a pipeline of permissively licensed components with a language-mix controller in the middle:
|
| 28 |
+
|
| 29 |
+
```
|
| 30 |
+
mic ─► Silero VAD ─► Dynamic ASR (CTC, streaming partials) ─► per-word language ID + mix estimate
|
| 31 |
+
│ end-of-turn, barge-in, backchannels │ {eng: 0.8, ibo: 0.2, pattern: greeting:ibo}
|
| 32 |
+
▼ ▼
|
| 33 |
+
stop playback ◄── barge-in Qwen3-30B-A3B reply (3 candidates, inline <eng>/<ibo>/... tags)
|
| 34 |
+
▼
|
| 35 |
+
validator: mix distance, tags, greeting, length, loops, echo ─► regenerate / fallback
|
| 36 |
+
▼
|
| 37 |
+
speaker ◄── Dynamic Accent Flash-DPO TTS (chunk streaming, code-switching voice) ◄──┘
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
**Status: research demo.** Igbo, Yoruba and Hausa beyond greetings and simple phrases are weak (the reply LLM's limit, not the voice's). Read the limitations before using it for anything.
|
| 41 |
+
|
| 42 |
+
## Components and licences
|
| 43 |
+
|
| 44 |
+
| stage | component | licence |
|
| 45 |
+
|---|---|---|
|
| 46 |
+
| voice activity / turn-taking | [Silero VAD v5](https://github.com/snakers4/silero-vad) | MIT |
|
| 47 |
+
| speech recognition | **[Dynamic ASR](https://huggingface.co/Modularcomputing/dynamic-asr)** `real` (Meta `omniASR_CTC_1B_v2` fine-tuned on licensed Nigerian speech; language-agnostic CTC + Latin-script mask). Fallback: stock [Meta Omnilingual ASR](https://github.com/facebookresearch/omnilingual-asr) `omniASR_CTC_1B` | weights CC-BY-SA-4.0 (base Apache-2.0) / Apache-2.0 |
|
| 48 |
+
| language ID / mix | Dynamic Accent `tagger.py` (rules) + [GlotLID v3](https://huggingface.co/cis-lmu/glotlid) sliding windows | Apache-2.0 / Apache-2.0 |
|
| 49 |
+
| reply LLM | [Qwen/Qwen3-30B-A3B-Instruct-2507-FP8](https://huggingface.co/Qwen/Qwen3-30B-A3B-Instruct-2507-FP8) served with [vLLM](https://github.com/vllm-project/vllm) | Apache-2.0 / Apache-2.0 |
|
| 50 |
+
| text-to-speech | Dynamic Accent **Flash-DPO** ([Modularcomputing/dynamic-accent-pro-v1.1](https://huggingface.co/Modularcomputing/dynamic-accent-pro-v1.1), `checkpoints/flash`) on [Fun-CosyVoice3-0.5B-2512](https://huggingface.co/FunAudioLLM/Fun-CosyVoice3-0.5B-2512) | weights CC-BY-SA-4.0 (base Apache-2.0) |
|
| 51 |
+
| diacritic restoration (only when a Yoruba/Igbo span has no tone marks) | Dynamic Accent ByT5 restorer (base `google/byt5-small`) | CC-BY-SA-4.0 (base Apache-2.0) |
|
| 52 |
+
| this repo's code | orchestration, mix controller, validator, servers | **Apache-2.0** |
|
| 53 |
+
|
| 54 |
+
Evaluation-only models (not part of the agent): Whisper large-v3-turbo (MIT), omniASR CTC-300M / LLM-1B / LLM-3B (Apache-2.0), Mistral-Small-3.2-24B-Instruct-2506 (Apache-2.0), MADLAD-400-3B-MT (Apache-2.0, back-translation).
|
| 55 |
+
Evaluation data: WAXAL test splits (Media Trust ibo/yor/hau/pcm subsets, CC-BY-SA-4.0), FLEURS validation (CC-BY-4.0).
|
| 56 |
+
Not used, on licence grounds: African Voices / WaZoBia data, NaijaVoices (non-commercial), N-ATLaS (1,000-user cap, share-alike). Gemma 3 was not tested (gated).
|
| 57 |
+
|
| 58 |
+
This repo ships **code and config only**; each model is downloaded from its own repository under its own licence. Audio samples are made with the CC-BY-SA-4.0 TTS and are CC-BY-SA-4.0.
|
| 59 |
+
|
| 60 |
+
## How the language mix works
|
| 61 |
+
|
| 62 |
+
1. **Per-word language ID.** The release tagger labels every word (`eng/pcm/ibo/yor/hau`) from diacritics, lexicons and Pidgin markers; ASR text has no punctuation or tone marks, so GlotLID scores 4-word sliding windows and confident windows override the rule labels. A sentence-level pass turns English-looking sentences with Pidgin markers into Pidgin.
|
| 63 |
+
2. **Mix estimate.** Word shares plus patterns: `greeting:<lang>`, `closing:<lang>`, `discourse:<lang>`, intra- vs inter-sentential switching, blended 30 % with the conversation so far. ASR-misspelt greetings ("nde wo", "sanno", "dalo") are matched against a variant list.
|
| 64 |
+
3. **Constrained reply.** The LLM gets a natural-language target ("English about 80 %, Igbo about 20 %; open with a short Igbo greeting"), a phrasebook of correct greetings/thanks/closings, four examples, and must tag every span (`<eng>…</eng> <ibo>…</ibo>`), which is exactly the input format of the Dynamic Accent TTS.
|
| 65 |
+
4. **Validator.** Each of 3 candidates is scored: tag format, total-variation distance between target and reply mix (declared tags averaged with re-measured LID), every target language ≥ 15 % present, greeting kept, 2–40 words, no loops, no echo of the user's words. The best passing candidate wins; otherwise one stricter round of 3 more; otherwise a fallback (best candidate, or for an Igbo/Yoruba/Hausa/Pidgin-led turn a correct phrasebook opener in that language plus a short clarification request).
|
| 66 |
+
|
| 67 |
+
## Duplex behaviour
|
| 68 |
+
|
| 69 |
+
- 32 ms Silero frames; speech start after 96 ms; end of turn after 600 ms of silence.
|
| 70 |
+
- **Speculative start:** ASR → LLM → first TTS chunk begin at 256 ms of silence; audio is released at end of turn, and dropped if the user resumes.
|
| 71 |
+
- **Barge-in:** 192 ms of user speech while the agent speaks (or has committed to speak) stops playback at once (client queue flushed); the interrupted reply is kept in history as what was actually played + `[interrupted]`.
|
| 72 |
+
- **Backchannels:** during a long user turn (≥ 4.5 s), at a 288–448 ms pause, at most every 10 s: "Mm-hmm" (English) / "Ehen" (Pidgin, Igbo, Yoruba) / "To" (Hausa).
|
| 73 |
+
- **Streaming TTS:** reply cut into a short first chunk (≤ 6–10 words, at a comma/greeting/conjunction), then sentences ≤ 22 words, each synthesised while the previous one plays.
|
| 74 |
+
|
| 75 |
+
## Run it
|
| 76 |
+
|
| 77 |
+
Tested on one H100 80 GB (vLLM 0.32, torch 2.x). Set up the Dynamic Accent TTS from `Modularcomputing/dynamic-accent-pro-v1.1` as its README describes, then:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
pip install -r requirements.txt # duplex server env (the TTS env)
|
| 81 |
+
pip install "omnilingual-asr>=0.2.0" huggingface_hub fastapi uvicorn # ASR worker env (can be a separate venv)
|
| 82 |
+
# ASR choice: ASR_MODEL=dynamic-asr (default) | dynamic-asr-cs (experimental code-switch) | omni (stock omniASR_CTC_1B)
|
| 83 |
+
pip install vllm # LLM server env
|
| 84 |
+
DA_REL=/path/to/dynamic-accent-pro-v1.1 ./launch.sh # -> http://localhost:7860 (use headphones)
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Offline scripted conversations (same components, simulated real-time clock; latency = end-of-turn wait + measured compute):
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
cd code && python offline.py convo scenarios.json out/ # demo conversations -> wav + stereo (user L / agent R) + json log
|
| 91 |
+
python offline.py eval list.jsonl out/ 40 # batch latency / mix / barge-in eval on audio clips
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
## Evaluation
|
| 95 |
+
|
| 96 |
+
All numbers from one H100 80 GB with the three processes sharing the GPU. Latency = end of the user's speech (VAD) to the first agent audio sample, from the offline driver: the end-of-turn wait is simulated frame by frame and every compute stage is timed on the real GPU (network and audio-device buffers excluded; the live WebSocket path measured 1.9-2.5 s over the public internet on two test turns). Held-out data only; whether Meta's ASR saw WAXAL/FLEURS in training is unknown.
|
| 97 |
+
|
| 98 |
+
### ASR (229 held-out clips; WER / CER; CER without diacritics in brackets)
|
| 99 |
+
|
| 100 |
+
| model | eng | pcm | ibo | yor | hau | cs | latency p50 / p90 per clip | CS mix TVD after ASR (rules / +GlotLID windows) |
|
| 101 |
+
|---|---|---|---|---|---|---|---|---|
|
| 102 |
+
| omniASR_CTC_1B | 0.14 / 0.07 (0.07) | 0.38 / 0.17 (0.17) | 0.63 / 0.20 (0.16) | 0.49 / 0.19 (0.12) | 0.30 / 0.09 (0.09) | 0.33 / 0.13 (0.10) | 44 / 50 ms | 0.402 / 0.277 |
|
| 103 |
+
| omniASR_CTC_300M | 0.13 / 0.05 (0.05) | 0.58 / 0.23 (0.23) | 0.70 / 0.21 (0.17) | 0.59 / 0.21 (0.14) | 0.40 / 0.13 (0.13) | 0.40 / 0.14 (0.12) | 27 / 30 ms | 0.381 / 0.381 |
|
| 104 |
+
| omniASR_LLM_1B | 0.07 / 0.03 (0.03) | 0.33 / 0.15 (0.15) | 0.54 / 0.17 (0.12) | 0.40 / 0.16 (0.11) | 0.30 / 0.09 (0.09) | 0.32 / 0.14 (0.11) | 1037 / 1802 ms | 0.4 / 0.4 |
|
| 105 |
+
| omniASR_LLM_3B | 0.06 / 0.03 (0.03) | 0.43 / 0.18 (0.18) | 0.63 / 0.21 (0.15) | 0.35 / 0.14 (0.10) | 0.30 / 0.09 (0.09) | 0.32 / 0.15 (0.13) | 848 / 1760 ms | 0.352 / 0.352 |
|
| 106 |
+
| whisper_turbo | 0.03 / 0.01 (0.01) | 0.42 / 0.21 (0.21) | 1.06 / 0.41 (0.34) | 1.05 / 0.52 (0.29) | 0.90 / 0.33 (0.32) | 0.81 / 0.62 (0.61) | 119 / 208 ms | 0.51 / 0.51 |
|
| 107 |
+
| omniASR_CTC_1B_latin | 0.14 / 0.07 (0.07) | 0.38 / 0.17 (0.17) | 0.63 / 0.20 (0.16) | 0.49 / 0.19 (0.12) | 0.30 / 0.09 (0.09) | 0.33 / 0.13 (0.10) | 45 / 54 ms | 0.402 / 0.277 |
|
| 108 |
+
| omniASR_CTC_1B_v2 (stock base) | 0.10 / 0.02 (0.02) | 0.34 / 0.12 (0.12) | 0.46 / 0.13 (0.11) | 0.40 / 0.13 (0.10) | 0.32 / 0.11 (0.11) | 0.29 / 0.10 (0.09) | same CTC-1B arch, not re-timed (≈ CTC_1B) | not measured |
|
| 109 |
+
| **Dynamic ASR real** (default) | 0.10 / 0.03 (0.03) | 0.19 / 0.07 (0.07) | 0.32 / 0.09 (0.08) | 0.35 / 0.12 (0.08) | 0.25 / 0.08 (0.08) | 0.24 / 0.08 (0.06) | same CTC-1B arch, not re-timed (≈ CTC_1B) | not measured |
|
| 110 |
+
| Dynamic ASR code-switch (experimental) | 0.09 / 0.02 (0.02) | 0.19 / 0.08 (0.08) | 0.32 / 0.10 (0.08) | 0.35 / 0.12 (0.08) | 0.25 / 0.08 (0.08) | 0.22 / 0.07 (0.06) | same CTC-1B arch, not re-timed (≈ CTC_1B) | not measured |
|
| 111 |
+
|
| 112 |
+
**Note:** the end-to-end latency tables below (and the per-clip ASR latency column) were measured with `omniASR_CTC_1B` as the ASR, before Dynamic ASR existed. Dynamic ASR is the same size and architecture (1B wav2vec2 CTC, greedy decoding), so latency is expected to be similar but was not re-measured. Dynamic ASR rows use the same 229 clips and scorer; see the [Dynamic ASR card](https://huggingface.co/Modularcomputing/dynamic-asr) for the code-switch tests.
|
| 113 |
+
|
| 114 |
+
LID on reference CS text (upper bound): word accuracy 0.9, mix TVD 0.133.
|
| 115 |
+
|
| 116 |
+
### Reply LLM (28 prompts: 16 mixed/greeting/Pidgin/English written prompts + 12 native WAXAL sentences ibo/yor/hau)
|
| 117 |
+
|
| 118 |
+
| model | subset | first candidate passes | regen rate | fallback | mean mix TVD | GlotLID agrees with span tag | judge 1-5 (back-translated) | LLM s (n=3) |
|
| 119 |
+
|---|---|---|---|---|---|---|---|---|
|
| 120 |
+
| mistral-small-3.2-24b | mixed | 0.93 | 0.07 | 0.0 | 0.027 | 1.0 | 4 | 0.433 |
|
| 121 |
+
| mistral-small-3.2-24b | native | 0.92 | 0.08 | 0.0 | 0.008 | 1.0 | 1 | 0.584 |
|
| 122 |
+
| mistral-small-3.2-24b | all | 0.89 | 0.07 | 0.0 | 0.017 | 1.0 | 2.79 | 0.497 |
|
| 123 |
+
| qwen3-30b-a3b-2507 | mixed | 0.86 | 0.07 | 0.0 | 0.018 | 1.0 | 4.43 | 0.277 |
|
| 124 |
+
| qwen3-30b-a3b-2507 | native | 0.92 | 0.0 | 0.0 | 0.029 | 1.0 | 1.58 | 0.41 |
|
| 125 |
+
| qwen3-30b-a3b-2507 | all | 0.89 | 0.04 | 0.0 | 0.021 | 1.0 | 3.25 | 0.317 |
|
| 126 |
+
|
| 127 |
+
### Latency, batch eval (80 turns; seconds, p50 / p90)
|
| 128 |
+
|
| 129 |
+
| stage | p50 | p90 |
|
| 130 |
+
|---|---|---|
|
| 131 |
+
| end-of-turn wait (VAD silence) | 0.576 | 0.576 |
|
| 132 |
+
| ASR final | 0.036 | 0.042 |
|
| 133 |
+
| LID + mix | 0.013 | 0.03 |
|
| 134 |
+
| LLM + validator rounds | 0.389 | 0.791 |
|
| 135 |
+
| TTS first chunk | 0.863 | 1.69 |
|
| 136 |
+
| compute total (ASR->first chunk) | 1.381 | 2.211 |
|
| 137 |
+
| **end of user speech -> first agent audio** | 1.637 | 2.467 |
|
| 138 |
+
|
| 139 |
+
Mix adherence (batch_eval): mean TVD(target, reply) 0.124 (p90 0.451); first candidate passes 0.588; regenerated 0.037; fallback 0.15 (safe phrasebook 0.113); 39/80 turns had a multi-language target.
|
| 140 |
+
|
| 141 |
+
### Latency, demos (19 turns; seconds, p50 / p90)
|
| 142 |
+
|
| 143 |
+
| stage | p50 | p90 |
|
| 144 |
+
|---|---|---|
|
| 145 |
+
| end-of-turn wait (VAD silence) | 0.576 | 0.576 |
|
| 146 |
+
| ASR final | 0.057 | 0.069 |
|
| 147 |
+
| LID + mix | 0.005 | 0.016 |
|
| 148 |
+
| LLM + validator rounds | 0.246 | 0.537 |
|
| 149 |
+
| TTS first chunk | 0.795 | 1.168 |
|
| 150 |
+
| compute total (ASR->first chunk) | 1.139 | 1.488 |
|
| 151 |
+
| **end of user speech -> first agent audio** | 1.395 | 1.744 |
|
| 152 |
+
|
| 153 |
+
Mix adherence (demos): mean TVD(target, reply) 0.084 (p90 0.162); first candidate passes 0.789; regenerated 0.0; fallback 0.053 (safe phrasebook 0.053); 11/19 turns had a multi-language target.
|
| 154 |
+
|
| 155 |
+
Barge-in (batch eval, n=40): user speech onset -> agent audio stopped p50 0.235 s, p90 0.3 s (VAD detection p50 0.195 s + 40 ms output flush).
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
## Demos
|
| 159 |
+
|
| 160 |
+
`samples/` holds four scripted conversations made with the offline driver (agent: Flash-DPO male anchor). **User turns in these samples are TTS-synthesised** (Flash female voices), not real people. Each has a JSON log with the ASR text, the target mix, the reply, the validator outcome and per-stage latency.
|
| 161 |
+
|
| 162 |
+
## Limitations (read before use)
|
| 163 |
+
|
| 164 |
+
- **Igbo, Yoruba and Hausa content is weak beyond greetings, thanks and short phrases.** The reply LLM (and every permissive LLM we tried) parrots or garbles longer native-language replies; a back-translation judge scored native-language replies 1.6/5 versus 4.4/5 for mixed/greeting/Pidgin prompts. The validator catches echoing and drift and falls back to an honest short reply, which means native-led turns often get "Sannu! Sorry, I did not catch all of that…".
|
| 165 |
+
- ASR on accented or synthetic speech misspells Igbo/Yoruba greetings (and loses tone marks), which can hide the user's language from the mix estimate; a variant list covers common greetings only.
|
| 166 |
+
- Pidgin vs English is the hardest LID boundary; Pidgin detection uses marker words.
|
| 167 |
+
- Latency p90 is driven by the TTS first chunk (autoregressive 0.5B LM, ~17 ms per audio token, ~0.4-0.6 x real time on a shared H100).
|
| 168 |
+
- Barge-in uses VAD only: without headphones or echo cancellation the agent's own voice can interrupt it. Backchannels are rule-based and can be too frequent on long read speech.
|
| 169 |
+
- No safety filtering beyond the LLM's own; replies can be wrong. Research demo, not a product.
|
| 170 |
+
|
| 171 |
+
## Citation / credits
|
| 172 |
+
|
| 173 |
+
Built by Modular on Meta Omnilingual ASR, Silero VAD, GlotLID, Qwen3, vLLM, CosyVoice3 and the Dynamic Accent TTS (trained on licensed data only: WAXAL, FLEURS, Open.Bible, NaijaS2ST, IgboAPI, IgboSynCorp, BibleTTS, OpenSLR).
|
code/asr_server.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streaming-capable ASR worker (runs in ~/venv_omni): Meta Omnilingual ASR CTC (Apache-2.0), language-agnostic decoding,
|
| 2 |
+
so code-switched speech needs no language hint. POST /asr body = raw float32 mono 16 kHz -> {"text", "ms"}.
|
| 3 |
+
Partial hypotheses = re-decoding the growing buffer (CTC is cheap: ~10-40 ms per call on H100)."""
|
| 4 |
+
import os, time, numpy as np, torch
|
| 5 |
+
import soundfile # noqa: F401 (loads the bundled libsndfile before fairseq2n looks for it)
|
| 6 |
+
from fastapi import FastAPI, Request
|
| 7 |
+
from omnilingual_asr.models.inference.pipeline import ASRInferencePipeline
|
| 8 |
+
# ASR model selection (env):
|
| 9 |
+
# ASR_MODEL=dynamic-asr (default) -> Dynamic ASR real-speech model (Modularcomputing/dynamic-asr, real/model.pt)
|
| 10 |
+
# ASR_MODEL=dynamic-asr-cs -> Dynamic ASR experimental code-switch model (codeswitch-experimental/model.pt)
|
| 11 |
+
# ASR_MODEL=omni -> stock Meta omniASR (ASR_CARD, default omniASR_CTC_1B)
|
| 12 |
+
# ASR_CKPT=<local .pt> -> any local fine-tuned state_dict on top of ASR_CARD
|
| 13 |
+
# Dynamic ASR = fine-tuned omniASR_CTC_1B_v2 (same CTC architecture; needs omnilingual-asr>=0.2.0, or patch_v2.sh on 0.1.0).
|
| 14 |
+
# If the Dynamic ASR weights cannot be loaded, the worker falls back to stock omniASR_CTC_1B.
|
| 15 |
+
LC = {"ibo": "ibo_Latn", "yor": "yor_Latn", "hau": "hau_Latn", "pcm": "pcm_Latn", "eng": "eng_Latn"}
|
| 16 |
+
import unicodedata, types
|
| 17 |
+
import omnilingual_asr.models.inference.pipeline as PL
|
| 18 |
+
DYN = {"dynamic-asr": "real/model.pt", "dynamic-asr-cs": "codeswitch-experimental/model.pt"}
|
| 19 |
+
MODEL = os.environ.get("ASR_MODEL", "dynamic-asr")
|
| 20 |
+
CKPT = os.environ.get("ASR_CKPT", "")
|
| 21 |
+
CARD = os.environ.get("ASR_CARD", "omniASR_CTC_1B_v2" if (MODEL in DYN or CKPT) else "omniASR_CTC_1B")
|
| 22 |
+
|
| 23 |
+
def _load():
|
| 24 |
+
global CARD, CKPT, MODEL
|
| 25 |
+
if MODEL in DYN:
|
| 26 |
+
CARD = "omniASR_CTC_1B_v2" # Dynamic ASR is always on the v2 base
|
| 27 |
+
if MODEL in DYN and not CKPT:
|
| 28 |
+
try:
|
| 29 |
+
from huggingface_hub import hf_hub_download
|
| 30 |
+
CKPT = hf_hub_download(os.environ.get("ASR_REPO", "Modularcomputing/dynamic-asr"), DYN[MODEL])
|
| 31 |
+
except Exception as e:
|
| 32 |
+
print("Dynamic ASR download failed, falling back to omniASR_CTC_1B:", repr(e)[:200], flush=True)
|
| 33 |
+
CARD, MODEL, CKPT = "omniASR_CTC_1B", "omni", ""
|
| 34 |
+
try:
|
| 35 |
+
pipe = ASRInferencePipeline(model_card=CARD)
|
| 36 |
+
if CKPT:
|
| 37 |
+
ck = torch.load(CKPT, map_location="cpu", weights_only=False)
|
| 38 |
+
print("ASR_CKPT", CKPT, pipe.model.load_state_dict({k: v.to(pipe.dtype) for k, v in ck["model"].items()}), flush=True)
|
| 39 |
+
del ck
|
| 40 |
+
return pipe
|
| 41 |
+
except Exception as e:
|
| 42 |
+
if CARD == "omniASR_CTC_1B":
|
| 43 |
+
raise
|
| 44 |
+
print("Dynamic ASR load failed (", repr(e)[:200], ") -> falling back to omniASR_CTC_1B", flush=True)
|
| 45 |
+
CARD, MODEL, CKPT = "omniASR_CTC_1B", "omni", ""
|
| 46 |
+
return ASRInferencePipeline(model_card=CARD)
|
| 47 |
+
|
| 48 |
+
p = _load()
|
| 49 |
+
LATIN_ONLY = os.environ.get("ASR_LATIN_ONLY", "1") == "1"
|
| 50 |
+
|
| 51 |
+
def _ok(sx):
|
| 52 |
+
for c in sx:
|
| 53 |
+
cat = unicodedata.category(c)
|
| 54 |
+
if cat[0] == "M": # combining marks: only the generic combining diacritics block
|
| 55 |
+
if not ("\u0300" <= c <= "\u036f"):
|
| 56 |
+
return False
|
| 57 |
+
elif c.isalpha() and "LATIN" not in unicodedata.name(c, ""):
|
| 58 |
+
return False
|
| 59 |
+
return True
|
| 60 |
+
|
| 61 |
+
_BAD = {}
|
| 62 |
+
def _apply_latin(self, batch):
|
| 63 |
+
"""CTC greedy decoding restricted to Latin-script tokens: all five target languages are written in Latin script, and the
|
| 64 |
+
language-agnostic CTC otherwise drifts into Arabic/other scripts on accented or synthetic speech."""
|
| 65 |
+
bl = PL.BatchLayout(batch.source_seqs.shape, seq_lens=batch.source_seq_lens, device=batch.source_seqs.device)
|
| 66 |
+
logits, bl_out = self.model(batch.source_seqs, bl)
|
| 67 |
+
V = logits.shape[-1]
|
| 68 |
+
if V not in _BAD:
|
| 69 |
+
bad = [i for i in range(V) if not _ok(self.token_decoder(torch.tensor([i])))]
|
| 70 |
+
_BAD[V] = torch.tensor(bad, dtype=torch.long, device=logits.device); print("latin mask: blocked", len(bad), "of", V, flush=True)
|
| 71 |
+
logits[..., _BAD[V]] = -float("inf")
|
| 72 |
+
pred = torch.argmax(logits, dim=-1); out = []
|
| 73 |
+
for i in range(pred.shape[0]):
|
| 74 |
+
seq = pred[i][: bl_out.seq_lens[i]]; m = torch.ones(seq.shape[0], dtype=torch.bool, device=seq.device); m[1:] = seq[1:] != seq[:-1]
|
| 75 |
+
out.append(self.token_decoder(seq[m]))
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
if LATIN_ONLY and "CTC" in CARD:
|
| 79 |
+
p._apply_model_wav2vec2asr = types.MethodType(_apply_latin, p)
|
| 80 |
+
app = FastAPI()
|
| 81 |
+
w = np.zeros(16000, np.float32); p.transcribe([{"waveform": w, "sample_rate": 16000}], batch_size=1)
|
| 82 |
+
|
| 83 |
+
@app.post("/asr")
|
| 84 |
+
async def asr(req: Request, lang: str = ""):
|
| 85 |
+
y = np.frombuffer(await req.body(), dtype=np.float32).copy()
|
| 86 |
+
if len(y) < 1600:
|
| 87 |
+
return {"text": "", "ms": 0.0}
|
| 88 |
+
t = time.perf_counter()
|
| 89 |
+
kw = {} if "CTC" in CARD or not lang else dict(lang=[LC.get(lang, "eng_Latn")])
|
| 90 |
+
h = p.transcribe([{"waveform": y, "sample_rate": 16000}], batch_size=1, **kw)[0]
|
| 91 |
+
return {"text": h, "ms": round(1000 * (time.perf_counter() - t), 1), "card": CARD}
|
| 92 |
+
|
| 93 |
+
@app.get("/health")
|
| 94 |
+
def health():
|
| 95 |
+
return {"ok": True, "model": MODEL, "card": CARD, "ckpt": CKPT}
|
code/eval/analyze.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aggregate offline batch-eval + demo logs into results/eval_summary.json (latency per stage p50/p90, barge-in, mix adherence)."""
|
| 2 |
+
import json, glob, sys, statistics as st, os
|
| 3 |
+
base = sys.argv[1] if len(sys.argv) > 1 else "results/out1"
|
| 4 |
+
demo_dir = sys.argv[2] if len(sys.argv) > 2 else f"{base}/demos"
|
| 5 |
+
def q(x, p):
|
| 6 |
+
x = sorted(v for v in x if v is not None); return round(x[min(len(x) - 1, int(p * len(x)))], 3) if x else None
|
| 7 |
+
def pq(x): return dict(p50=q(x, .5), p90=q(x, .9), n=len([v for v in x if v is not None]))
|
| 8 |
+
E = json.load(open(f"{base}/eval/eval_turns.json"))
|
| 9 |
+
turns = [t for e in E for t in e["turns"]]
|
| 10 |
+
D = [t for f in sorted(glob.glob(f"{demo_dir}/*.json")) for t in json.load(open(f))["turns"]]
|
| 11 |
+
def lat(T):
|
| 12 |
+
return {k: pq([t.get(k) for t in T]) for k in ("latency_s", "eot_wait_s", "asr_s", "mix_s", "llm_s", "tts_first_s", "compute_to_first_audio_s")}
|
| 13 |
+
def mixstats(T):
|
| 14 |
+
n = len(T)
|
| 15 |
+
return dict(n=n, tvd_mean=round(st.mean(t["tvd"] for t in T if t["tvd"] is not None), 3), tvd_p90=q([t["tvd"] for t in T], .9),
|
| 16 |
+
first_candidate_ok=round(sum(t["first_cand_ok"] for t in T) / n, 3), regen_rate=round(sum(t["how"].startswith("regen") for t in T) / n, 3),
|
| 17 |
+
fallback_rate=round(sum(t["how"] in ("best_failed", "plain_matrix", "safe_phrasebook") for t in T) / n, 3), safe_fallback_rate=round(sum(t["how"] == "safe_phrasebook" for t in T) / n, 3),
|
| 18 |
+
multi_lang_targets=sum(len(t["target"]["mix"]) > 1 for t in T))
|
| 19 |
+
by_lang = {}
|
| 20 |
+
for e in E:
|
| 21 |
+
t = e["turns"][0]; by_lang.setdefault(e["lang"], []).append(t)
|
| 22 |
+
barge = [t["barge"] for t in turns if t.get("barge")]
|
| 23 |
+
out = dict(
|
| 24 |
+
batch_eval=dict(desc="40 real held-out clips (WAXAL test ibo/yor/hau/pcm, FLEURS val eng, concatenated code-switch), each user turn followed by a barge-in clip 1.0 s into the reply (that clip is answered too)",
|
| 25 |
+
latency=lat(turns), mix=mixstats(turns), per_lang_first_turn={l: dict(lat=pq([t["latency_s"] for t in T]), **mixstats(T)) for l, T in by_lang.items()},
|
| 26 |
+
barge_in=dict(n=len(barge), reaction_s=pq([b["reaction_s"] for b in barge]), detected_minus_onset=pq([b["detected"] - b["user_onset"] for b in barge])),
|
| 27 |
+
backchannels=sum(len(t.get("backchannels", [])) for t in turns)),
|
| 28 |
+
demos=dict(n_turns=len(D), latency=lat(D), mix=mixstats(D), barge_in=[t["barge"] for t in D if t.get("barge")], backchannels=sum(len(t.get("backchannels", [])) for t in D)))
|
| 29 |
+
os.makedirs("results", exist_ok=True); json.dump(out, open("results/eval_summary.json", "w"), indent=1); print(json.dumps(out, indent=1))
|
code/eval/asr_omni.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""omniASR candidates on the eval set: transcripts + single-clip latency + CTC streaming (prefix re-decode) sim.
|
| 2 |
+
~/venv_omni/bin/python asr_omni.py <card> -> /scratch/duplex/asr_eval/hyp_<card>.jsonl"""
|
| 3 |
+
import json, sys, time, numpy as np, soundfile as sf, torch
|
| 4 |
+
from omnilingual_asr.models.inference.pipeline import ASRInferencePipeline
|
| 5 |
+
LC = {"ibo": "ibo_Latn", "yor": "yor_Latn", "hau": "hau_Latn", "pcm": "pcm_Latn", "eng": "eng_Latn"}
|
| 6 |
+
card = sys.argv[1]; D = "/scratch/duplex/asr_eval"
|
| 7 |
+
R = [json.loads(l) for l in open(f"{D}/list.jsonl")]
|
| 8 |
+
p = ASRInferencePipeline(model_card=card)
|
| 9 |
+
ctc = "CTC" in card
|
| 10 |
+
def tr(w, lang):
|
| 11 |
+
kw = {} if ctc or lang is None else dict(lang=[LC[lang]])
|
| 12 |
+
return p.transcribe([{"waveform": w, "sample_rate": 16000}], batch_size=1, **kw)[0]
|
| 13 |
+
w0 = sf.read(R[0]["wav"], dtype="float32")[0]
|
| 14 |
+
for _ in range(3): tr(w0, "eng")
|
| 15 |
+
out = open(f"{D}/hyp_{card}.jsonl", "w")
|
| 16 |
+
for r in R:
|
| 17 |
+
w = sf.read(r["wav"], dtype="float32")[0]
|
| 18 |
+
lang = r["lang"] if r["lang"] != "cs" else None # LLM cards: CS clips get no language hint
|
| 19 |
+
torch.cuda.synchronize(); t = time.perf_counter(); h = tr(w, lang); torch.cuda.synchronize(); dt = time.perf_counter() - t
|
| 20 |
+
rec = dict(id=r["id"], hyp=h, lat=round(dt, 4), dur=r["dur"])
|
| 21 |
+
if ctc and r["dur"] > 4 and int(r["id"][-3:]) % 4 == 0: # streaming sim: re-decode growing prefix every 0.64 s
|
| 22 |
+
parts = []
|
| 23 |
+
for e in np.arange(0.64, r["dur"], 0.64):
|
| 24 |
+
t = time.perf_counter(); hp = tr(w[: int(e * 16000)], None); parts.append((round(float(e), 2), hp, round(time.perf_counter() - t, 4)))
|
| 25 |
+
rec["partials"] = parts
|
| 26 |
+
out.write(json.dumps(rec, ensure_ascii=False) + "\n"); out.flush()
|
| 27 |
+
print("OMNI_DONE", card)
|
code/eval/asr_score.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Score ASR candidates: WER/CER per language (lowercased, punctuation stripped), diacritic-folded CER, latency, streaming
|
| 2 |
+
partial stability, and how well the mix estimate survives ASR (TVD between mix(ref text) and mix(hyp)). ~/venv/bin/python asr_score.py"""
|
| 3 |
+
import json, glob, re, unicodedata, statistics as st, os, sys
|
| 4 |
+
import jiwer
|
| 5 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 6 |
+
import mix as M
|
| 7 |
+
D = "/scratch/duplex/asr_eval"
|
| 8 |
+
R = {json.loads(l)["id"]: json.loads(l) for l in open(f"{D}/list.jsonl")}
|
| 9 |
+
def norm(s):
|
| 10 |
+
s = unicodedata.normalize("NFC", s.lower()); s = re.sub(r"[^\w\s']", " ", s); return re.sub(r"\s+", " ", s).strip()
|
| 11 |
+
def fold(s):
|
| 12 |
+
return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
|
| 13 |
+
def q(x, p):
|
| 14 |
+
x = sorted(x); return x[min(len(x) - 1, int(p * len(x)))] if x else None
|
| 15 |
+
out = {}
|
| 16 |
+
for f in sorted(glob.glob(f"{D}/hyp_*.jsonl")):
|
| 17 |
+
if len(sys.argv) > 1 and sys.argv[1] not in f: continue
|
| 18 |
+
tag = f.split("hyp_")[1][:-6]; H = [json.loads(l) for l in open(f)]
|
| 19 |
+
if len(H) < len(R) * 0.9: print("incomplete", tag, len(H)); continue
|
| 20 |
+
res = {}
|
| 21 |
+
for lg in ["eng", "pcm", "ibo", "yor", "hau", "cs"]:
|
| 22 |
+
S = [h for h in H if R[h["id"]]["lang"] == lg]
|
| 23 |
+
refs = [norm(R[h["id"]]["text"]) for h in S]; hyps = [norm(h["hyp"]) or "x" for h in S]
|
| 24 |
+
res[lg] = dict(n=len(S), wer=round(jiwer.wer(refs, hyps), 3), cer=round(jiwer.cer(refs, hyps), 3),
|
| 25 |
+
cer_nodiac=round(jiwer.cer([fold(r) for r in refs], [fold(h) for h in hyps]), 3))
|
| 26 |
+
lat = [h["lat"] for h in H]; rtf = [h["lat"] / h["dur"] for h in H]
|
| 27 |
+
res["latency"] = dict(p50_s=round(q(lat, .5), 3), p90_s=round(q(lat, .9), 3), rtf_p50=round(q(rtf, .5), 4))
|
| 28 |
+
P = [h for h in H if h.get("partials")]
|
| 29 |
+
if P: # stability: share of final words already present (in order prefix) in the partial 1.28 s before the end
|
| 30 |
+
stab, plat = [], []
|
| 31 |
+
for h in P:
|
| 32 |
+
fin = norm(h["hyp"]).split(); parts = h["partials"]
|
| 33 |
+
if len(parts) >= 2 and fin:
|
| 34 |
+
pw = norm(parts[-2][1]).split(); k = 0
|
| 35 |
+
while k < min(len(pw), len(fin)) and pw[k] == fin[k]: k += 1
|
| 36 |
+
stab.append(k / max(1, len(pw)))
|
| 37 |
+
plat += [p[2] for p in parts]
|
| 38 |
+
res["streaming"] = dict(n=len(P), partial_prefix_stability=round(st.mean(stab), 3) if stab else None, partial_call_p50_s=round(q(plat, .5), 4), partial_call_p90_s=round(q(plat, .9), 4))
|
| 39 |
+
# mix estimate after ASR (CS clips): TVD(mix(ref), mix(hyp)), rules-only and with GlotLID
|
| 40 |
+
tv = {}
|
| 41 |
+
for lid in ("none", "glotlid"):
|
| 42 |
+
v = []
|
| 43 |
+
for h in H:
|
| 44 |
+
r = R[h["id"]]
|
| 45 |
+
if r["lang"] != "cs": continue
|
| 46 |
+
ref_mix = {}
|
| 47 |
+
for l in r["wlang"]: ref_mix[l] = ref_mix.get(l, 0) + 1 / len(r["wlang"])
|
| 48 |
+
v.append(M.tvd(ref_mix, M.estimate(h["hyp"], lid=lid, asr=True)["mix"]))
|
| 49 |
+
tv[lid] = round(st.mean(v), 3) if v else None
|
| 50 |
+
mono = [M.estimate(h["hyp"], lid=lid, asr=True)["mix"].get(R[h["id"]]["lang"], 0) for h in H if R[h["id"]]["lang"] in ("ibo", "yor", "hau", "pcm")]
|
| 51 |
+
tv[lid + "_mono_true_share"] = round(st.mean(mono), 3)
|
| 52 |
+
res["cs_mix_tvd_after_asr"] = tv
|
| 53 |
+
out[tag] = res
|
| 54 |
+
# LID on reference text (upper bound for the mix estimate)
|
| 55 |
+
tv = {}
|
| 56 |
+
for lid in ("none", "glotlid"):
|
| 57 |
+
v = []; acc = []
|
| 58 |
+
for r in R.values():
|
| 59 |
+
if r["lang"] != "cs": continue
|
| 60 |
+
ref_mix = {}
|
| 61 |
+
for l in r["wlang"]: ref_mix[l] = ref_mix.get(l, 0) + 1 / len(r["wlang"])
|
| 62 |
+
e = M.estimate(r["text"], lid=lid); v.append(M.tvd(ref_mix, e["mix"]))
|
| 63 |
+
if len(e["labels"]) == len(r["wlang"]): acc += [a == b for a, b in zip(e["labels"], r["wlang"])]
|
| 64 |
+
tv[lid] = dict(mix_tvd=round(st.mean(v), 3), word_acc=round(sum(acc) / len(acc), 3) if acc else None, n_words=len(acc))
|
| 65 |
+
out["_lid_on_reference_text"] = tv
|
| 66 |
+
json.dump(out, open(f"{D}/asr_scores.json", "w"), indent=1); print(json.dumps(out, indent=1))
|
code/eval/asr_via_server.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Re-run the ASR eval through the live ASR server (e.g. CTC_1B + Latin-script mask): python asr_via_server.py <tag>"""
|
| 2 |
+
import json, sys, time, httpx, soundfile as sf, numpy as np
|
| 3 |
+
D = "/scratch/duplex/asr_eval"; c = httpx.Client(timeout=30); out = open(f"{D}/hyp_{sys.argv[1]}.jsonl", "w")
|
| 4 |
+
for l in open(f"{D}/list.jsonl"):
|
| 5 |
+
r = json.loads(l); y = sf.read(r["wav"], dtype="float32")[0]
|
| 6 |
+
t = time.perf_counter(); h = c.post("http://127.0.0.1:8002/asr", content=y.tobytes()).json(); dt = time.perf_counter() - t
|
| 7 |
+
out.write(json.dumps(dict(id=r["id"], hyp=h["text"], lat=round(dt, 4), dur=r["dur"]), ensure_ascii=False) + "\n")
|
| 8 |
+
print("VIA_DONE")
|
code/eval/asr_whisper.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Whisper candidates (MIT) via transformers: ~/venv/bin/python asr_whisper.py <hf_id> <tag>"""
|
| 2 |
+
import json, sys, time, torch, soundfile as sf
|
| 3 |
+
from transformers import pipeline
|
| 4 |
+
mid, tag = sys.argv[1], sys.argv[2]; D = "/scratch/duplex/asr_eval"
|
| 5 |
+
WL = {"eng": "en", "yor": "yo", "hau": "ha", "pcm": "en", "ibo": None, "cs": None}
|
| 6 |
+
asr = pipeline("automatic-speech-recognition", model=mid, torch_dtype=torch.float16, device="cuda")
|
| 7 |
+
R = [json.loads(l) for l in open(f"{D}/list.jsonl")]
|
| 8 |
+
out = open(f"{D}/hyp_{tag}.jsonl", "w")
|
| 9 |
+
for i, r in enumerate(R):
|
| 10 |
+
w = sf.read(r["wav"], dtype="float32")[0]
|
| 11 |
+
gk = {"task": "transcribe"}
|
| 12 |
+
if WL[r["lang"]]: gk["language"] = WL[r["lang"]]
|
| 13 |
+
torch.cuda.synchronize(); t = time.perf_counter()
|
| 14 |
+
h = asr({"raw": w, "sampling_rate": 16000}, generate_kwargs=gk)["text"].strip()
|
| 15 |
+
torch.cuda.synchronize(); dt = time.perf_counter() - t
|
| 16 |
+
out.write(json.dumps(dict(id=r["id"], hyp=h, lat=round(dt, 4), dur=r["dur"]), ensure_ascii=False) + "\n"); out.flush()
|
| 17 |
+
print("WHISPER_DONE", tag)
|
code/eval/build_asr_eval.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build held-out ASR/LID eval set -> /scratch/duplex/asr_eval/{wav/*.wav, list.jsonl}
|
| 2 |
+
Mono: WAXAL test (ibo/yor/hau/pcm, 40 each, spread over speakers), FLEURS val eng (30).
|
| 3 |
+
CS: 40 synthetic code-switched clips = two held-out clips of different languages concatenated (0.25 s gap), ref = joined text + per-word lang labels.
|
| 4 |
+
All splits are test/val/dev splits never used in TTS training; whether the ASR models saw them is unknown (noted in report)."""
|
| 5 |
+
import json, os, random, numpy as np, soundfile as sf, librosa
|
| 6 |
+
H = os.path.expanduser("~"); OUT = "/scratch/duplex/asr_eval"; os.makedirs(OUT + "/wav", exist_ok=True)
|
| 7 |
+
random.seed(7)
|
| 8 |
+
def rows(d, pred):
|
| 9 |
+
R = [json.loads(l) for l in open(f"{d}/meta.jsonl")]
|
| 10 |
+
return [r for r in R if pred(r)]
|
| 11 |
+
def audio(d, r):
|
| 12 |
+
a = np.memmap(f"{d}/pcm24k.i16", dtype=np.int16, mode="r")
|
| 13 |
+
y = a[r["off24"]: r["off24"] + r["n24"]].astype(np.float32) / 32768
|
| 14 |
+
return librosa.resample(y, orig_sr=24000, target_sr=16000)
|
| 15 |
+
def pick(R, n):
|
| 16 |
+
by = {}
|
| 17 |
+
for r in R: by.setdefault(r.get("spk"), []).append(r)
|
| 18 |
+
for v in by.values(): random.shuffle(v)
|
| 19 |
+
out = []
|
| 20 |
+
while len(out) < n and any(by.values()):
|
| 21 |
+
for k in list(by):
|
| 22 |
+
if by[k] and len(out) < n: out.append(by[k].pop())
|
| 23 |
+
return out
|
| 24 |
+
items = []
|
| 25 |
+
W = f"{H}/data3/waxal"
|
| 26 |
+
for L in ["ibo", "yor", "hau", "pcm"]:
|
| 27 |
+
for r in pick(rows(W, lambda r: r["lang"] == L and r["split"] == "test" and 1.5 < r["dur"] < 14), 40):
|
| 28 |
+
items.append(("waxal_test", W, r, L))
|
| 29 |
+
F = f"{H}/data3/fleurs"
|
| 30 |
+
for r in pick(rows(F, lambda r: r["lang"] == "eng" and r["split"] == "val" and r["dur"] < 16), 30): items.append(("fleurs_val", F, r, "eng"))
|
| 31 |
+
recs = []; pool = {}
|
| 32 |
+
for src, d, r, L in items:
|
| 33 |
+
y = audio(d, r); i = f"{L}_{len(recs):03d}"
|
| 34 |
+
sf.write(f"{OUT}/wav/{i}.wav", y, 16000)
|
| 35 |
+
rec = dict(id=i, wav=f"{OUT}/wav/{i}.wav", lang=L, text=r["text"], src=f"{src}:{r['id']}", dur=round(len(y) / 16000, 2),
|
| 36 |
+
wlang=[L] * len(r["text"].split()))
|
| 37 |
+
recs.append(rec); pool.setdefault(L, []).append((rec, y))
|
| 38 |
+
pairs = [("eng", "ibo"), ("ibo", "eng"), ("eng", "yor"), ("yor", "eng"), ("eng", "hau"), ("hau", "eng"), ("pcm", "eng"), ("eng", "pcm"), ("pcm", "ibo"), ("pcm", "yor")]
|
| 39 |
+
for k in range(40):
|
| 40 |
+
a, b = pairs[k % len(pairs)]
|
| 41 |
+
(ra, ya), (rb, yb) = random.choice(pool[a]), random.choice(pool[b])
|
| 42 |
+
y = np.concatenate([ya, np.zeros(4000, np.float32), yb]); i = f"cs_{k:03d}"
|
| 43 |
+
sf.write(f"{OUT}/wav/{i}.wav", y, 16000)
|
| 44 |
+
recs.append(dict(id=i, wav=f"{OUT}/wav/{i}.wav", lang="cs", langs=[a, b], text=ra["text"] + " " + rb["text"], src=f"concat:{ra['id']}+{rb['id']}",
|
| 45 |
+
dur=round(len(y) / 16000, 2), wlang=ra["wlang"] + rb["wlang"]))
|
| 46 |
+
with open(f"{OUT}/list.jsonl", "w") as f:
|
| 47 |
+
for r in recs: f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 48 |
+
from collections import Counter; print(Counter(r["lang"] for r in recs)); print("BUILD_DONE", len(recs))
|
code/eval/llm_eval.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compare response LLMs on mix-matched replies.
|
| 2 |
+
gen <tag> prompts.json out.json (needs a vLLM server at LLM_BASE; full Responder path: mix -> n=3 candidates -> validator -> regen)
|
| 3 |
+
bt out1.json [out2.json ...] (MADLAD-400-3B (Apache-2.0) back-translates the non-English spans of user + reply into English)
|
| 4 |
+
judge out1.json ... (server LLM rates coherence/relevance of the back-translated reply, 1-5)
|
| 5 |
+
summary out1.json ..."""
|
| 6 |
+
import sys, json, re, os, statistics as st
|
| 7 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 8 |
+
TAG = re.compile(r"<(eng|ibo|yor|hau|pcm)>(.*?)</\1>", re.S)
|
| 9 |
+
|
| 10 |
+
def add_native(P):
|
| 11 |
+
"""+4 native WAXAL test transcripts per ibo/yor/hau (monolingual user turns)"""
|
| 12 |
+
L = [json.loads(l) for l in open("/scratch/duplex/asr_eval/list.jsonl")]
|
| 13 |
+
for lg in ("ibo", "yor", "hau"):
|
| 14 |
+
rs = [r for r in L if r["lang"] == lg and len(r["text"].split()) >= 6][:4]
|
| 15 |
+
P += [dict(id=f"n_{lg}_{i}", kind=f"{lg} native", text=r["text"]) for i, r in enumerate(rs)]
|
| 16 |
+
return P
|
| 17 |
+
|
| 18 |
+
def gen(tag, pf, out):
|
| 19 |
+
from respond import Responder
|
| 20 |
+
import mix as M
|
| 21 |
+
R = Responder(); res = []
|
| 22 |
+
for p in add_native(json.load(open(pf))):
|
| 23 |
+
R.style = None
|
| 24 |
+
r = R.reply(p["text"])
|
| 25 |
+
spans = TAG.findall(r["reply"]); glot = []
|
| 26 |
+
for l, t in spans:
|
| 27 |
+
if l in ("ibo", "yor", "hau") and len(t.split()) >= 2:
|
| 28 |
+
s = M.tagger("glotlid")._lid_scores(t); glot.append(max(s, key=s.get) == l)
|
| 29 |
+
res.append(dict(p, model=tag, **{k: r[k] for k in ("target", "reply", "reply_mix", "tvd", "how", "first_cand_ok", "any_first_round_ok", "t_llm", "rounds")}, glot_ok=glot))
|
| 30 |
+
print(json.dumps({"id": p["id"], "reply": r["reply"], "how": r["how"], "tvd": r["tvd"]}, ensure_ascii=False), flush=True)
|
| 31 |
+
json.dump(res, open(out, "w"), ensure_ascii=False, indent=1)
|
| 32 |
+
|
| 33 |
+
def bt(files):
|
| 34 |
+
import torch
|
| 35 |
+
from transformers import T5ForConditionalGeneration, T5Tokenizer
|
| 36 |
+
import mix as M
|
| 37 |
+
tok = T5Tokenizer.from_pretrained("google/madlad400-3b-mt"); m = T5ForConditionalGeneration.from_pretrained("google/madlad400-3b-mt", torch_dtype=torch.bfloat16).cuda().eval()
|
| 38 |
+
def tr(s):
|
| 39 |
+
ids = tok("<2en> " + s, return_tensors="pt").input_ids.cuda()
|
| 40 |
+
with torch.no_grad(): o = m.generate(ids, max_new_tokens=3 * len(s.split()) + 16, num_beams=2, no_repeat_ngram_size=4)
|
| 41 |
+
return tok.decode(o[0], skip_special_tokens=True)
|
| 42 |
+
def en(tagged):
|
| 43 |
+
return " ".join(t if l in ("eng", "pcm") else tr(t) for l, t in TAG.findall(tagged))
|
| 44 |
+
for f in files:
|
| 45 |
+
R = json.load(open(f))
|
| 46 |
+
for r in R:
|
| 47 |
+
r["user_bt"] = en(M.tagger("glotlid").tag(r["text"])); r["reply_bt"] = en(r["reply"])
|
| 48 |
+
json.dump(R, open(f, "w"), ensure_ascii=False, indent=1); print("bt", f, flush=True)
|
| 49 |
+
|
| 50 |
+
def judge(files):
|
| 51 |
+
from llm import LLM
|
| 52 |
+
J = LLM()
|
| 53 |
+
for f in files:
|
| 54 |
+
R = json.load(open(f))
|
| 55 |
+
for r in R:
|
| 56 |
+
q = (f"A user said (English gloss): \"{r['user_bt']}\"\nThe assistant replied (English back-translation): \"{r['reply_bt']}\"\n"
|
| 57 |
+
"Rate from 1 to 5 how coherent, sensible and relevant the reply is as a spoken assistant response (5 = fully sensible and on-topic, "
|
| 58 |
+
"1 = nonsense or off-topic). Output only the digit.")
|
| 59 |
+
o, _ = J.generate([{"role": "user", "content": q}], n=1, temperature=0.0, max_tokens=4)
|
| 60 |
+
m = re.search(r"[1-5]", o[0]); r["judge"] = int(m.group()) if m else None
|
| 61 |
+
json.dump(R, open(f, "w"), ensure_ascii=False, indent=1); print("judged", f, flush=True)
|
| 62 |
+
|
| 63 |
+
def summary(files):
|
| 64 |
+
rows = []
|
| 65 |
+
for f in files:
|
| 66 |
+
R = json.load(open(f)); tag = R[0]["model"]
|
| 67 |
+
def grp(pred):
|
| 68 |
+
S = [r for r in R if pred(r)]
|
| 69 |
+
if not S: return None
|
| 70 |
+
g = [x for r in S for x in r["glot_ok"]]
|
| 71 |
+
j = [r["judge"] for r in S if r.get("judge")]
|
| 72 |
+
return dict(n=len(S), first_cand_ok=round(sum(r["first_cand_ok"] for r in S) / len(S), 2), regen=round(sum(r["how"] != "first_round" for r in S) / len(S), 2),
|
| 73 |
+
fallback=round(sum(r["how"] in ("best_failed", "plain_matrix") for r in S) / len(S), 2), tvd=round(st.mean(r["tvd"] or 0 for r in S), 3),
|
| 74 |
+
glot_ok=round(sum(g) / len(g), 2) if g else None, judge=round(st.mean(j), 2) if j else None, llm_s=round(st.median(r["t_llm"] for r in S), 3))
|
| 75 |
+
rows.append(dict(model=tag, all=grp(lambda r: True), mixed=grp(lambda r: not r["id"].startswith("n_") and not r["id"].startswith("e_")),
|
| 76 |
+
native=grp(lambda r: r["id"].startswith("n_"))))
|
| 77 |
+
print(json.dumps(rows, indent=1)); return rows
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
c = sys.argv[1]
|
| 81 |
+
if c == "gen": gen(sys.argv[2], sys.argv[3], sys.argv[4])
|
| 82 |
+
elif c == "bt": bt(sys.argv[2:])
|
| 83 |
+
elif c == "judge": judge(sys.argv[2:])
|
| 84 |
+
else: json.dump(summary(sys.argv[2:]), open("/scratch/duplex/llm_summary.json", "w"), indent=1)
|
code/eval/llm_prompts.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{"id":"g_ibo_1","kind":"eng+ibo greeting","text":"Ndewo! Good morning. Please, can you remind me what time the market closes today?"},
|
| 3 |
+
{"id":"g_ibo_2","kind":"eng+ibo greeting/closing","text":"Kedu? I am looking for a good place to buy fresh fish in Owerri. Daalụ."},
|
| 4 |
+
{"id":"g_yor_1","kind":"eng+yor greeting","text":"Ẹ káàárọ̀. I want to know how to get to the airport from Ikeja."},
|
| 5 |
+
{"id":"g_yor_2","kind":"eng+yor greeting","text":"Ẹ kú ìrọ̀lẹ́. My phone battery drains very fast, what should I do?"},
|
| 6 |
+
{"id":"g_hau_1","kind":"eng+hau greeting","text":"Sannu. Please help me plan a small birthday party for my daughter."},
|
| 7 |
+
{"id":"g_hau_2","kind":"eng+hau greeting/closing","text":"Barka da yamma. What is a good dinner I can cook in thirty minutes? Na gode."},
|
| 8 |
+
{"id":"g_pcm_1","kind":"eng+pcm opener","text":"How far? I need to write an email to my landlord about the leaking roof."},
|
| 9 |
+
{"id":"i_eng_ibo","kind":"intra eng/ibo","text":"Biko, how do I reset my bank app password? I forgot it."},
|
| 10 |
+
{"id":"i_eng_yor","kind":"intra eng/yor","text":"Jọ̀ọ́, can you check the weather for Lagos today?"},
|
| 11 |
+
{"id":"i_eng_hau","kind":"intra eng/hau","text":"Don Allah, how much is a bus ticket from Kano to Abuja?"},
|
| 12 |
+
{"id":"p_1","kind":"pcm","text":"Abeg, I don try call my brother but network no gree. Wetin I fit do?"},
|
| 13 |
+
{"id":"p_2","kind":"pcm","text":"My guy, this traffic no be here o. I go late for work today."},
|
| 14 |
+
{"id":"p_3","kind":"pcm","text":"Wetin be the best way to save money every month? Salary no dey last."},
|
| 15 |
+
{"id":"p_4","kind":"pcm+eng","text":"I wan start small business, maybe selling drinks. You think say e go work?"},
|
| 16 |
+
{"id":"e_1","kind":"eng","text":"Can you recommend a good book to read this weekend?"},
|
| 17 |
+
{"id":"e_2","kind":"eng","text":"What should I pack for a three day trip to Calabar?"}
|
| 18 |
+
]
|
code/eval/make_report.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fill REPORT.md tables from results/*.json. python make_report.py <eval_summary.json> <asr_scores.json> <llm_summary.json> -> prints markdown blocks"""
|
| 2 |
+
import json, sys
|
| 3 |
+
ev, asr, llm = (json.load(open(f)) for f in sys.argv[1:4])
|
| 4 |
+
L = ["eng", "pcm", "ibo", "yor", "hau", "cs"]
|
| 5 |
+
o = ["### ASR (229 held-out clips; WER / CER; CER without diacritics in brackets)", "",
|
| 6 |
+
"| model | " + " | ".join(L) + " | latency p50 / p90 per clip | CS mix TVD after ASR (rules / +GlotLID windows) |", "|---" * (len(L) + 3) + "|"]
|
| 7 |
+
for k, v in asr.items():
|
| 8 |
+
if k.startswith("_"): continue
|
| 9 |
+
o.append(f"| {k} | " + " | ".join(f"{v[l]['wer']:.2f} / {v[l]['cer']:.2f} ({v[l]['cer_nodiac']:.2f})" for l in L) +
|
| 10 |
+
f" | {v['latency']['p50_s']*1000:.0f} / {v['latency']['p90_s']*1000:.0f} ms | {v['cs_mix_tvd_after_asr'].get('none')} / {v['cs_mix_tvd_after_asr'].get('glotlid')} |")
|
| 11 |
+
o += ["", f"LID on reference CS text (upper bound): word accuracy {asr['_lid_on_reference_text']['glotlid']['word_acc']}, mix TVD {asr['_lid_on_reference_text']['glotlid']['mix_tvd']}.", ""]
|
| 12 |
+
o += ["### Reply LLM (28 prompts: 16 mixed/greeting/Pidgin/English written prompts + 12 native WAXAL sentences ibo/yor/hau)", "",
|
| 13 |
+
"| model | subset | first candidate passes | regen rate | fallback | mean mix TVD | GlotLID agrees with span tag | judge 1-5 (back-translated) | LLM s (n=3) |", "|---|---|---|---|---|---|---|---|---|"]
|
| 14 |
+
for r in llm:
|
| 15 |
+
for sub in ("mixed", "native", "all"):
|
| 16 |
+
g = r[sub]
|
| 17 |
+
o.append(f"| {r['model']} | {sub} | {g['first_cand_ok']} | {g['regen']} | {g['fallback']} | {g['tvd']} | {g['glot_ok']} | {g['judge']} | {g['llm_s']} |")
|
| 18 |
+
for name in ("batch_eval", "demos"):
|
| 19 |
+
e = ev[name]; la = e["latency"]
|
| 20 |
+
o += ["", f"### Latency, {name.replace('_', ' ')} ({la['latency_s']['n']} turns; seconds, p50 / p90)", "", "| stage | p50 | p90 |", "|---|---|---|"]
|
| 21 |
+
for k, lab in [("eot_wait_s", "end-of-turn wait (VAD silence)"), ("asr_s", "ASR final"), ("mix_s", "LID + mix"), ("llm_s", "LLM + validator rounds"), ("tts_first_s", "TTS first chunk"),
|
| 22 |
+
("compute_to_first_audio_s", "compute total (ASR->first chunk)"), ("latency_s", "**end of user speech -> first agent audio**")]:
|
| 23 |
+
o.append(f"| {lab} | {la[k]['p50']} | {la[k]['p90']} |")
|
| 24 |
+
m = e["mix"]
|
| 25 |
+
o += ["", f"Mix adherence ({name}): mean TVD(target, reply) {m['tvd_mean']} (p90 {m['tvd_p90']}); first candidate passes {m['first_candidate_ok']}; regenerated {m['regen_rate']}; fallback {m['fallback_rate']}" + (f" (safe phrasebook {m.get('safe_fallback_rate')})" if 'safe_fallback_rate' in m else "") + f"; {m['multi_lang_targets']}/{m['n']} turns had a multi-language target."]
|
| 26 |
+
b = ev["batch_eval"]["barge_in"]
|
| 27 |
+
o += ["", f"Barge-in (batch eval, n={b['n']}): user speech onset -> agent audio stopped p50 {b['reaction_s']['p50']} s, p90 {b['reaction_s']['p90']} s (VAD detection p50 {b['detected_minus_onset']['p50']} s + 40 ms output flush)."]
|
| 28 |
+
print("\n".join(o))
|
code/eval/ws_test.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live-path test of server.py over a real WebSocket (real-time paced 32 ms frames).
|
| 2 |
+
python ws_test.py <ws_url> <user1.wav> [<barge.wav>] -> prints events; saves ws_test_agent.wav"""
|
| 3 |
+
import asyncio, json, sys, time, numpy as np, soundfile as sf, librosa, websockets
|
| 4 |
+
async def main(url, w1, w2=None):
|
| 5 |
+
def load(p):
|
| 6 |
+
y, sr = sf.read(p, dtype="float32"); y = y.mean(1) if y.ndim > 1 else y
|
| 7 |
+
return (librosa.resample(y, orig_sr=sr, target_sr=16000) * 32767).astype(np.int16)
|
| 8 |
+
u1 = load(w1); u2 = load(w2) if w2 else None
|
| 9 |
+
t0 = time.time(); ev = []; audio = []; first_audio = [None]; got_reply = [False]; got_reply = [False]
|
| 10 |
+
async with websockets.connect(url, max_size=2 ** 24) as ws:
|
| 11 |
+
async def rx():
|
| 12 |
+
async for m in ws:
|
| 13 |
+
t = round(time.time() - t0, 3)
|
| 14 |
+
if isinstance(m, bytes):
|
| 15 |
+
audio.append(np.frombuffer(m, np.int16)); first_audio[0] = first_audio[0] or (t if got_reply[0] else None); ev.append((t, "audio", len(m) // 2 / 24000))
|
| 16 |
+
else:
|
| 17 |
+
d = json.loads(m); got_reply[0] = got_reply[0] or d.get("type") == "reply"; ev.append((t, d.get("type"), {k: v for k, v in d.items() if k != "type"})); print(t, d, flush=True)
|
| 18 |
+
task = asyncio.create_task(rx())
|
| 19 |
+
async def send(y):
|
| 20 |
+
for i in range(0, len(y) - 511, 512):
|
| 21 |
+
await ws.send(y[i:i + 512].tobytes()); await asyncio.sleep(0.032)
|
| 22 |
+
z = np.zeros(512, np.int16)
|
| 23 |
+
await send(u1); print(round(time.time() - t0, 3), "user1 sent", flush=True)
|
| 24 |
+
for _ in range(int(6.0 / 0.032)):
|
| 25 |
+
await ws.send(z.tobytes()); await asyncio.sleep(0.032)
|
| 26 |
+
if u2 is not None and first_audio[0] and time.time() - t0 > first_audio[0] + 1.0: break
|
| 27 |
+
if u2 is not None:
|
| 28 |
+
print(round(time.time() - t0, 3), "barge-in user2 starts", flush=True); await send(u2)
|
| 29 |
+
for _ in range(int(8 / 0.032)):
|
| 30 |
+
await ws.send(z.tobytes()); await asyncio.sleep(0.032)
|
| 31 |
+
task.cancel()
|
| 32 |
+
if audio: sf.write("ws_test_agent.wav", np.concatenate(audio).astype(np.float32) / 32768, 24000)
|
| 33 |
+
print("WS_TEST_DONE", "first_audio_at", first_audio[0])
|
| 34 |
+
asyncio.run(main(*sys.argv[1:]))
|
code/llm.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Response LLM: a reply that mirrors the user's language mix, with inline language tags for the Flash TTS.
|
| 2 |
+
Talks to an OpenAI-compatible vLLM server (default http://127.0.0.1:8001/v1, served name "llm")."""
|
| 3 |
+
import os, time, json, httpx
|
| 4 |
+
|
| 5 |
+
PHRASEBOOK = """Phrasebook (use these exact spellings when you need them):
|
| 6 |
+
Igbo: Ndewo (hello), Kedu? (how are you?), Ọ dị mma (it is fine/good), Ụtụtụ ọma (good morning), Ehihie ọma (good afternoon), Mgbede ọma (good evening), Nnọọ (welcome), Daalụ (thank you), Biko (please), Ee (yes), Mba (no), Ka ọ dị (goodbye), Chineke gozie gị (God bless you)
|
| 7 |
+
Yoruba: Ẹ káàárọ̀ (good morning), Ẹ káàsán (good afternoon), Ẹ kú ìrọ̀lẹ́ (good evening), Ṣé dáadáa ni? (are you well?), Ẹ kú àbọ̀ (welcome), Ẹ ṣé / Ẹ ṣéun (thank you), Jọ̀ọ́ (please), Bẹ́ẹ̀ ni (yes), Rárá (no), Ó dàbọ̀ (goodbye), Ẹ pẹ̀lẹ́ (sorry/hello)
|
| 8 |
+
Hausa: Sannu (hello), Barka da safiya (good morning), Barka da yamma (good evening), Ina kwana? (good morning, how was the night?), Lafiya lau (fine), Maraba (welcome), Na gode (thank you), Don Allah (please), Eh (yes), A'a (no), Sai an jima (see you later)
|
| 9 |
+
Nigerian Pidgin: How far? (hi), I dey (I'm fine), Abeg (please), Wetin (what), No wahala (no problem), Na so (that's right), Thank you well well, We go see later (goodbye)"""
|
| 10 |
+
|
| 11 |
+
SYSTEM = """You are Ada, a warm, quick Nigerian voice assistant in a live spoken conversation. Your words are spoken aloud by a code-switching text-to-speech voice.
|
| 12 |
+
Language rule (most important): reply in the SAME language mix as the user, as described in the [Language target] note. If the user speaks mostly English with an Igbo greeting, you reply mostly in English with an Igbo greeting. If the user speaks Pidgin, reply in Pidgin. If the user speaks Yoruba, Hausa or Igbo, reply mainly in that language. Never add a language the user did not use. You are the assistant: answer and help; never repeat the user's sentence as if it were your own.
|
| 13 |
+
Output format: wrap every stretch of words in a language tag: <eng>...</eng>, <pcm>...</pcm>, <ibo>...</ibo>, <yor>...</yor>, <hau>...</hau>. Nothing outside tags. One line.
|
| 14 |
+
Style: spoken, natural, friendly. Start with a short opener of one to three words followed by a comma or full stop (for example 'Okay,' 'Ehen,' 'No wahala!' 'Sure,' or a greeting), then one or two short sentences, at most 35 words in total. No lists, no emojis, no markdown. Write numbers as words. Ask a short follow-up question when it helps.
|
| 15 |
+
The user text comes from speech recognition: it has no punctuation and may misspell Igbo, Yoruba or Hausa words (for example "aca arow" for "Ẹ káàárọ̀", "baka da yamma" for "Barka da yamma", "dalo" for "Daalụ"). Understand what was meant, greet back in that language when the user greeted in it, and never copy misspelled words into your reply.
|
| 16 |
+
Write Igbo and Yoruba with their proper letters and tone marks when you can. Keep Igbo, Yoruba and Hausa simple and correct; prefer common phrases over long complex sentences.
|
| 17 |
+
""" + PHRASEBOOK
|
| 18 |
+
|
| 19 |
+
SHOTS = [
|
| 20 |
+
("Ndewo, good afternoon. Please, what time does the bank open on Saturday?",
|
| 21 |
+
"Match the user's language mix: English about 80%, Igbo about 20% of the words. The user greeted in Igbo: open with a short Igbo greeting.",
|
| 22 |
+
"<ibo>Ndewo!</ibo> <eng>On Saturdays most banks open at ten and close by two. Do you want me to check a particular branch?</eng>"),
|
| 23 |
+
("How far? Abeg, wetin I fit cook tonight wey no go take long?",
|
| 24 |
+
"Match the user's language mix: Nigerian Pidgin about 100% of the words. Reply only in Nigerian Pidgin.",
|
| 25 |
+
"<pcm>I dey o! You fit make jollof spaghetti with egg, e no go pass thirty minutes. You get pepper for house?</pcm>"),
|
| 26 |
+
("Ẹ káàárọ̀. I want to book a doctor's appointment for tomorrow.",
|
| 27 |
+
"Match the user's language mix: English about 80%, Yoruba about 20% of the words. The user greeted in Yoruba: open with a short Yoruba greeting.",
|
| 28 |
+
"<yor>Ẹ káàárọ̀!</yor> <eng>Sure, I can help with that. What time works best for you tomorrow?</eng>"),
|
| 29 |
+
("Sannu. Ina so in sayi sabuwar waya.",
|
| 30 |
+
"Match the user's language mix: Hausa about 100% of the words. Reply only in Hausa.",
|
| 31 |
+
"<hau>Sannu da zuwa! Akwai wayoyi masu kyau da yawa. Nawa ne kasafin kuɗinka?</hau>"),
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
def build_messages(user_text, target_desc, history=(), shots=True):
|
| 35 |
+
m = [{"role": "system", "content": SYSTEM}]
|
| 36 |
+
if shots:
|
| 37 |
+
for u, d, a in SHOTS:
|
| 38 |
+
m += [{"role": "user", "content": f"{u}\n[Language target] {d}"}, {"role": "assistant", "content": a}]
|
| 39 |
+
for u, a in list(history)[-6:]:
|
| 40 |
+
m += [{"role": "user", "content": u}, {"role": "assistant", "content": a}]
|
| 41 |
+
m.append({"role": "user", "content": f"{user_text}\n[Language target] {target_desc}"})
|
| 42 |
+
return m
|
| 43 |
+
|
| 44 |
+
class LLM:
|
| 45 |
+
def __init__(self, base=os.environ.get("LLM_BASE", "http://127.0.0.1:8001/v1"), model=os.environ.get("LLM_NAME", "llm"), thinking_kw=True):
|
| 46 |
+
self.base, self.model = base, model
|
| 47 |
+
self.cli = httpx.Client(timeout=60)
|
| 48 |
+
self.thinking_kw = thinking_kw
|
| 49 |
+
|
| 50 |
+
def generate(self, messages, n=3, temperature=0.7, max_tokens=110, top_p=0.9):
|
| 51 |
+
body = dict(model=self.model, messages=messages, n=n, temperature=temperature, top_p=top_p, max_tokens=max_tokens)
|
| 52 |
+
if self.thinking_kw:
|
| 53 |
+
body["chat_template_kwargs"] = {"enable_thinking": False}
|
| 54 |
+
t = time.perf_counter()
|
| 55 |
+
r = self.cli.post(self.base + "/chat/completions", json=body)
|
| 56 |
+
if r.status_code == 400 and self.thinking_kw: # template without the kwarg
|
| 57 |
+
self.thinking_kw = False; return self.generate(messages, n, temperature, max_tokens, top_p)
|
| 58 |
+
r.raise_for_status()
|
| 59 |
+
outs = [c["message"]["content"] or "" for c in r.json()["choices"]]
|
| 60 |
+
import re
|
| 61 |
+
outs = [re.sub(r"<think>.*?</think>", "", o, flags=re.S).strip() for o in outs]
|
| 62 |
+
return outs, time.perf_counter() - t
|
code/mix.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-word language ID + language-mix estimate for a user turn (and for a reply).
|
| 2 |
+
Reuses the Dynamic Accent release tagger (rule-based lexicons/diacritics/Pidgin markers, optional GlotLID clause scores).
|
| 3 |
+
estimate("Ndewo, how are you doing today?") ->
|
| 4 |
+
{"mix": {"eng": 0.67, "ibo": 0.33}, "matrix": "eng", "pattern": ["greeting:ibo"], "words": [...], "labels": [...]}"""
|
| 5 |
+
import re, unicodedata
|
| 6 |
+
from tagger import Tagger, fold
|
| 7 |
+
|
| 8 |
+
LANGS = ["eng", "pcm", "ibo", "yor", "hau"]
|
| 9 |
+
NAMES = {"eng": "English", "pcm": "Nigerian Pidgin", "ibo": "Igbo", "yor": "Yoruba", "hau": "Hausa"}
|
| 10 |
+
# greeting / closing / discourse lexicon (diacritics folded) -> language
|
| 11 |
+
GREET = {"ibo": ["nde wo", "ndewoo", "ndeewo", "kedu", "nnoo", "ututu oma", "ehihie oma", "mgbede oma", "ka chi fo", "kedu ka i mere", "i bola chi"],
|
| 12 |
+
"yor": ["e kaaro", "e kaasan", "e kurole", "e ku irole", "bawo", "se daadaa ni", "e nle", "pele", "e kaabo", "e ku abo", "ekaaro", "ekaasan", "ekaro", "e karo", "e kaaro o"],
|
| 13 |
+
"hau": ["sannu", "sanu", "sanno", "baka da yamma", "baka da safiya", "barka", "barka da safiya", "barka da yamma", "ina kwana", "ina wuni", "yaya dai", "lafiya lau", "maraba"],
|
| 14 |
+
"pcm": ["how far", "how body", "wetin dey", "how you dey", "how una dey", "i hail"]}
|
| 15 |
+
CLOSE = {"ibo": ["daalu", "dalu", "dalo", "daalo", "dalow", "ka o di", "ka emesia", "chukwu gozie"], "yor": ["e se", "o se", "o dabo", "e seun", "ese", "odabo"],
|
| 16 |
+
"hau": ["na gode", "sai an jima", "sai anjima", "nagode", "mun gode", "na godi", "nagodi"], "pcm": ["we go see", "thank you well well", "make we yarn later"]}
|
| 17 |
+
DISC = {"pcm": ["abeg", "abi", "sha", "o", "na so", "omo", "wahala"], "yor": ["sha", "o", "ehen", "abi"], "ibo": ["biko", "nna", "nne", "chai", "ehen", "ngwa"],
|
| 18 |
+
"hau": ["to", "kai", "wallahi", "haba"]}
|
| 19 |
+
|
| 20 |
+
_T = {}
|
| 21 |
+
def tagger(lid="glotlid"):
|
| 22 |
+
if lid not in _T:
|
| 23 |
+
try:
|
| 24 |
+
_T[lid] = Tagger(lid if lid != "none" else None)
|
| 25 |
+
except Exception as e: # no fasttext / GlotLID: rules only
|
| 26 |
+
print("tagger: GlotLID unavailable, rules only:", repr(e)[:120]); _T[lid] = Tagger(None)
|
| 27 |
+
return _T[lid]
|
| 28 |
+
|
| 29 |
+
def _find(fs, phrases):
|
| 30 |
+
for p in sorted(phrases, key=len, reverse=True):
|
| 31 |
+
if re.search(r"(^|\s)" + re.escape(p) + r"(\s|$)", fs):
|
| 32 |
+
return p
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
PCM_MARK = {"dey", "don", "abeg", "wetin", "una", "gree", "wahala", "sabi", "comot", "chop", "oya", "abi", "shey", "wey", "dem", "wan",
|
| 36 |
+
"pikin", "oga", "sef", "jare", "naim", "dis", "dat", "wahala", "kpatakpata", "ehn", "sha", "nor", "abeg", "jor", "ehen", "na"}
|
| 37 |
+
PCM_BI = {("no", "gree"), ("no", "be"), ("e", "no"), ("make", "we"), ("how", "far"), ("na", "im"), ("i", "go"), ("you", "go"), ("we", "go"), ("e", "go"),
|
| 38 |
+
("no", "go"), ("i", "fit"), ("you", "fit"), ("e", "don"), ("i", "don"), ("my", "guy"), ("na", "so"), ("well", "well"), ("no", "dey")}
|
| 39 |
+
|
| 40 |
+
def pidgin_relabel(words, labs, text):
|
| 41 |
+
"""sentence-level Pidgin: a sentence of English-labelled words with Pidgin markers is Pidgin (the word tagger only flips the marker's clause)."""
|
| 42 |
+
if not labs: return labs
|
| 43 |
+
labs = list(labs); f = [fold(w).strip(".,!?;:'\"") for w in words]
|
| 44 |
+
ends = [i for i, w in enumerate(words) if re.search(r"[.!?]$", w)] + [len(words) - 1]
|
| 45 |
+
s0 = 0
|
| 46 |
+
for e in sorted(set(ends)):
|
| 47 |
+
idx = list(range(s0, e + 1)); s0 = e + 1
|
| 48 |
+
if not idx: continue
|
| 49 |
+
hits = sum(1 for i in idx if f[i] in PCM_MARK or f[i] == "omo") + sum(1 for a, b in zip(idx, idx[1:]) if (f[a], f[b]) in PCM_BI and not ((f[a], f[b]) == ("how", "far") and a != idx[0]))
|
| 50 |
+
hits += 1 if f[idx[-1]] == "o" and len(idx) > 2 else 0
|
| 51 |
+
lat = [i for i in idx if labs[i] in ("eng", "pcm")]
|
| 52 |
+
if hits == 0 and len(lat) > 4 and all(labs[i] == "pcm" for i in lat):
|
| 53 |
+
for i in lat: labs[i] = "eng"
|
| 54 |
+
continue
|
| 55 |
+
if lat and (hits >= 2 or (hits >= 1 and len(idx) <= 8)) and sum(labs[i] == "pcm" for i in lat) < len(lat):
|
| 56 |
+
for i in lat: labs[i] = "pcm"
|
| 57 |
+
return labs
|
| 58 |
+
|
| 59 |
+
def window_relabel(words, labs, t, win=4, thr=0.5):
|
| 60 |
+
"""ASR text has no punctuation/diacritics, so the clause-level tagger sees one long 'English' clause. Slide a GlotLID window
|
| 61 |
+
over the words and let confident window votes override the rule labels (rule label keeps a weight-1 vote)."""
|
| 62 |
+
from collections import Counter
|
| 63 |
+
n = len(words)
|
| 64 |
+
if t.lid is None or n < 3: return labs
|
| 65 |
+
votes = [Counter({labs[i]: 1.0}) for i in range(n)]
|
| 66 |
+
for s0 in range(0, max(1, n - win + 1)):
|
| 67 |
+
sc = t._lid_scores(" ".join(words[s0:s0 + win])); l = max(sc, key=sc.get)
|
| 68 |
+
if sc[l] >= thr:
|
| 69 |
+
for i in range(s0, min(n, s0 + win)): votes[i][l] += sc[l]
|
| 70 |
+
return [v.most_common(1)[0][0] for v in votes]
|
| 71 |
+
|
| 72 |
+
def estimate(text, lid="glotlid", prior=None, prior_w=0.0, asr=False):
|
| 73 |
+
"""prior: a previous mix dict (conversation-level style), blended with weight prior_w."""
|
| 74 |
+
t = tagger(lid)
|
| 75 |
+
toks, labs = t.word_langs(text) if text.strip() else ([], [])
|
| 76 |
+
words = [w for _, _, w in toks]
|
| 77 |
+
if asr: labs = window_relabel(words, list(labs), t)
|
| 78 |
+
labs = pidgin_relabel(words, labs, text)
|
| 79 |
+
n = max(1, len(labs))
|
| 80 |
+
mix = {l: labs.count(l) / n for l in LANGS if labs.count(l)}
|
| 81 |
+
if prior and prior_w > 0:
|
| 82 |
+
keys = set(mix) | set(prior)
|
| 83 |
+
mix = {k: (1 - prior_w) * mix.get(k, 0) + prior_w * prior.get(k, 0) for k in keys}
|
| 84 |
+
s = sum(mix.values()) or 1; mix = {k: v / s for k, v in mix.items() if v / s >= 0.03}
|
| 85 |
+
fs = " " + re.sub(r"[^\w\s']", " ", fold(text)) + " "
|
| 86 |
+
fs = re.sub(r"\s+", " ", fs)
|
| 87 |
+
pat = []
|
| 88 |
+
head = " ".join(fs.split()[:5]); tail = " ".join(fs.split()[-5:])
|
| 89 |
+
for l in ("ibo", "yor", "hau", "pcm"):
|
| 90 |
+
g = _find(head, GREET[l]); c = _find(tail, CLOSE[l])
|
| 91 |
+
if g: pat.append(f"greeting:{l}")
|
| 92 |
+
if c: pat.append(f"closing:{l}")
|
| 93 |
+
d = [p for p in DISC[l] if re.search(r"(^|\s)" + re.escape(p) + r"(\s|$)", fs)]
|
| 94 |
+
if d and l in mix: pat.append(f"discourse:{l}")
|
| 95 |
+
# ASR often spells a greeting/closing so that the word tagger calls it English: give the matched words to that language
|
| 96 |
+
toks_f = fs.split()
|
| 97 |
+
for p in pat:
|
| 98 |
+
k, _, l = p.partition(":")
|
| 99 |
+
if k in ("greeting", "closing") and mix.get(l, 0) < 0.1 and labs:
|
| 100 |
+
phr = _find(head if k == "greeting" else tail, (GREET if k == "greeting" else CLOSE)[l]) or ""
|
| 101 |
+
share = min(0.5, len(phr.split()) / max(1, len(labs)))
|
| 102 |
+
m2 = max(mix, key=mix.get)
|
| 103 |
+
mix[m2] = max(0.0, mix[m2] - share); mix[l] = mix.get(l, 0) + share
|
| 104 |
+
mix = {a: b for a, b in mix.items() if b > 0.01}
|
| 105 |
+
# switch style: count switches between labelled runs
|
| 106 |
+
sw = sum(1 for a, b in zip(labs, labs[1:]) if a != b)
|
| 107 |
+
if len(mix) > 1:
|
| 108 |
+
pat.append("intra-sentential" if sw >= 3 else "inter-sentential")
|
| 109 |
+
matrix = max(mix, key=mix.get) if mix else "eng"
|
| 110 |
+
return dict(mix={k: round(v, 2) for k, v in sorted(mix.items(), key=lambda x: -x[1])}, matrix=matrix, pattern=pat,
|
| 111 |
+
words=words, labels=labs, n_words=len(labs), switches=sw)
|
| 112 |
+
|
| 113 |
+
def describe(est):
|
| 114 |
+
"""natural-language mix instruction for the LLM."""
|
| 115 |
+
mix, pat = est["mix"], est["pattern"]
|
| 116 |
+
if not mix:
|
| 117 |
+
return "Reply in English."
|
| 118 |
+
parts = [f"{NAMES[l]} about {round(100 * p / 5) * 5}%" for l, p in mix.items()]
|
| 119 |
+
s = f"Match the user's language mix: {', '.join(parts)} of the words. The main (matrix) language is {NAMES[est['matrix']]}."
|
| 120 |
+
for p in pat:
|
| 121 |
+
k, _, l = p.partition(":")
|
| 122 |
+
if k == "greeting": s += f" The user greeted in {NAMES[l]}: open with a short {NAMES[l]} greeting."
|
| 123 |
+
if k == "closing": s += f" The user closed in {NAMES[l]}: you may close in {NAMES[l]}."
|
| 124 |
+
if k == "discourse": s += f" Sprinkle a {NAMES[l]} discourse word or two the way the user did."
|
| 125 |
+
if "intra-sentential" in pat: s += " The user switches inside sentences; do the same naturally."
|
| 126 |
+
if len(mix) == 1: s += f" Reply only in {NAMES[est['matrix']]}."
|
| 127 |
+
return s
|
| 128 |
+
|
| 129 |
+
def tvd(a, b):
|
| 130 |
+
k = set(a) | set(b)
|
| 131 |
+
return 0.5 * sum(abs(a.get(x, 0) - b.get(x, 0)) for x in k)
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
import sys, json
|
| 135 |
+
for s in sys.argv[1:] or ["Ndewo! How are you doing today? I wan ask you something.", "Ẹ káàárọ̀, ṣé dáadáa ni? I need help with my account."]:
|
| 136 |
+
e = estimate(s); print(json.dumps({k: e[k] for k in ("mix", "matrix", "pattern")}, ensure_ascii=False), "|", describe(e))
|
code/offline.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline duplex driver: runs scripted conversations (or a batch eval) through the SAME components as the live server,
|
| 2 |
+
on a simulated clock. Mic audio is fed in 32 ms frames; compute stages are timed on the real GPU and placed on the timeline,
|
| 3 |
+
so latency = (end-of-turn silence wait) + ASR + mix/LID + LLM/validator + first TTS chunk.
|
| 4 |
+
python offline.py convo scenarios.json OUTDIR
|
| 5 |
+
python offline.py eval list.jsonl OUTDIR [N]"""
|
| 6 |
+
import os, sys, json, time, re, numpy as np, soundfile as sf, librosa, httpx
|
| 7 |
+
from turn import TurnDetector, FR, SR, FMS
|
| 8 |
+
from respond import Responder
|
| 9 |
+
from tts_engine import TTS
|
| 10 |
+
|
| 11 |
+
ASR_URL = os.environ.get("ASR_URL", "http://127.0.0.1:8002/asr")
|
| 12 |
+
_cli = httpx.Client(timeout=30)
|
| 13 |
+
def asr(y):
|
| 14 |
+
t = time.perf_counter(); r = _cli.post(ASR_URL, content=np.ascontiguousarray(y, np.float32).tobytes()).json()
|
| 15 |
+
return r["text"], time.perf_counter() - t
|
| 16 |
+
|
| 17 |
+
BC = {"eng": "<eng>Mm-hmm.</eng>", "pcm": "<pcm>Ehen.</pcm>", "ibo": "<ibo>Ehen.</ibo>", "yor": "<yor>Ehen.</yor>", "hau": "<hau>To.</hau>"}
|
| 18 |
+
def trim(w, sr, db=35):
|
| 19 |
+
yt, idx = librosa.effects.trim(w, top_db=db); return w[max(0, idx[0] - int(0.03 * sr)): idx[1] + int(0.05 * sr)]
|
| 20 |
+
|
| 21 |
+
class Sim:
|
| 22 |
+
def __init__(self, tts, resp, eot_ms=600):
|
| 23 |
+
self.tts, self.resp, self.eot_ms = tts, resp, eot_ms
|
| 24 |
+
self.osr = tts.sr
|
| 25 |
+
self.bc = {k: trim(tts.synth(v, seed=3), self.osr) for k, v in BC.items()}
|
| 26 |
+
import mix as M; M.estimate("warm up the GlotLID model", asr=True)
|
| 27 |
+
import logging; logging.getLogger("httpx").setLevel(logging.WARNING); logging.getLogger("httpcore").setLevel(logging.WARNING)
|
| 28 |
+
|
| 29 |
+
def run(self, turns, label=""):
|
| 30 |
+
td = TurnDetector(eot_ms=self.eot_ms)
|
| 31 |
+
T = 600; mic = np.zeros(int(T * SR), np.float32); agt = np.zeros(int(T * self.osr), np.float32)
|
| 32 |
+
f = 0; busy_until = 0.0; reply_start = None; hist = []; log = []; t_next = 0.5; cur_chunks = []; style_lang = "eng"
|
| 33 |
+
for k, tn in enumerate(turns):
|
| 34 |
+
u = tn["audio"]
|
| 35 |
+
if tn.get("barge_at") is not None and reply_start is not None:
|
| 36 |
+
start = reply_start + tn["barge_at"]
|
| 37 |
+
else:
|
| 38 |
+
start = max(t_next, busy_until + tn.get("gap", 0.7))
|
| 39 |
+
s0 = int(start * SR); mic[s0:s0 + len(u)] += u
|
| 40 |
+
e = np.abs(u) > 0.02; onset = start + (np.argmax(e) / SR if e.any() else 0)
|
| 41 |
+
rec = dict(turn=k, user_label=tn.get("label", ""), user_ref=tn.get("text", ""), user_start=round(start, 3), user_onset=round(onset, 3), backchannels=[])
|
| 42 |
+
ev_eot = None
|
| 43 |
+
while ev_eot is None:
|
| 44 |
+
fr = mic[f * FR:(f + 1) * FR]; f += 1; tnow = f * FMS
|
| 45 |
+
busy = tnow < busy_until
|
| 46 |
+
for name, info in td.feed(fr, agent_busy=busy):
|
| 47 |
+
if name == "barge" and busy:
|
| 48 |
+
t0 = time.perf_counter(); stop_overhead = time.perf_counter() - t0 + 0.040 # + 40 ms output buffer flush
|
| 49 |
+
t_stop = tnow + stop_overhead
|
| 50 |
+
a0 = int(t_stop * self.osr); fade = int(0.02 * self.osr)
|
| 51 |
+
agt[a0:a0 + fade] *= np.linspace(1, 0, fade)[: len(agt[a0:a0 + fade])]; agt[a0 + fade:] = 0
|
| 52 |
+
played = [c for c in cur_chunks if c[1] < t_stop]
|
| 53 |
+
if hist:
|
| 54 |
+
hist[-1] = (hist[-1][0], " ".join(c[0] for c in played) + " [interrupted]")
|
| 55 |
+
rec["barge"] = dict(user_onset=round(onset, 3), detected=round(tnow, 3), agent_stopped=round(t_stop, 3), reaction_s=round(t_stop - onset, 3),
|
| 56 |
+
agent_chunks_played=len(played), agent_chunks_total=len(cur_chunks))
|
| 57 |
+
busy_until = t_stop
|
| 58 |
+
elif name == "backchannel" and not busy:
|
| 59 |
+
w = self.bc.get(style_lang, self.bc["eng"]); a0 = int((tnow + 0.05) * self.osr); agt[a0:a0 + len(w)] += 0.8 * w
|
| 60 |
+
rec["backchannel_at"] = round(tnow, 3); rec["backchannels"].append(dict(t=round(tnow, 3), turn_s=round(info["turn_s"], 2)))
|
| 61 |
+
elif name == "eot":
|
| 62 |
+
ev_eot = (tnow, info)
|
| 63 |
+
T_eot, info = ev_eot
|
| 64 |
+
text, t_asr = asr(info["audio"])
|
| 65 |
+
t_start = info["speech_end"] + (td.spec_f * FMS if td.spec_f else (T_eot - info["speech_end"])) # speculative start at spec_ms of silence
|
| 66 |
+
r = self.resp.reply(text, hist)
|
| 67 |
+
style_lang = r["target"]["matrix"]
|
| 68 |
+
t_cmp = t_asr + r["t_total"]
|
| 69 |
+
prev_end = 0; cum = 0; first = None; cur_chunks = []; tts_times = []
|
| 70 |
+
for c, w, ds in self.tts.stream(r["reply"], seed=k):
|
| 71 |
+
cum += ds; tts_times.append(round(ds, 3))
|
| 72 |
+
avail = max(T_eot, t_start + t_cmp + cum); ps = max(avail, prev_end)
|
| 73 |
+
a0 = int(ps * self.osr); agt[a0:a0 + len(w)] += w
|
| 74 |
+
prev_end = ps + len(w) / self.osr; cur_chunks.append((c, ps, prev_end))
|
| 75 |
+
if first is None: first = ps
|
| 76 |
+
reply_start = first; busy_until = prev_end; t_next = prev_end
|
| 77 |
+
hist.append((text, r["reply"]))
|
| 78 |
+
rec.update(asr=text, asr_s=round(t_asr, 3), eot_wait_s=round(T_eot - info["speech_end"], 3), speech_end=round(info["speech_end"], 3),
|
| 79 |
+
target=r["target"], target_desc=r["target_desc"], reply=r["reply"], reply_mix=r["reply_mix"], tvd=r["tvd"], how=r["how"],
|
| 80 |
+
first_cand_ok=r["first_cand_ok"], rounds=r["rounds"], mix_s=r["t_mix"], llm_s=r["t_llm"], tts_first_s=tts_times[0] if tts_times else None, spec_start_s=round(t_start - info["speech_end"], 3), compute_to_first_audio_s=round(t_cmp + (tts_times[0] if tts_times else 0), 3),
|
| 81 |
+
tts_breakdown=getattr(self.tts, "last", None),
|
| 82 |
+
tts_chunks_s=tts_times, first_audio=round(first, 3) if first else None, reply_end=round(prev_end, 3),
|
| 83 |
+
latency_s=round(first - info["speech_end"], 3) if first else None)
|
| 84 |
+
log.append(rec); print(json.dumps({k2: rec[k2] for k2 in ("turn", "asr", "reply", "latency_s", "how")}, ensure_ascii=False), flush=True)
|
| 85 |
+
end = max(busy_until, f * FMS) + 0.6
|
| 86 |
+
mic24 = librosa.resample(mic[: int(end * SR)], orig_sr=SR, target_sr=self.osr)
|
| 87 |
+
n = min(len(mic24), int(end * self.osr)); a = agt[:n]; m = mic24[:n]
|
| 88 |
+
return dict(log=log, mono=np.clip(0.9 * (m + a), -1, 1), stereo=np.stack([m, a], 1), sr=self.osr)
|
| 89 |
+
|
| 90 |
+
def user_audio(tts, tn):
|
| 91 |
+
if tn.get("wav"):
|
| 92 |
+
y, sr = sf.read(tn["wav"], dtype="float32"); y = y.mean(1) if y.ndim > 1 else y
|
| 93 |
+
return librosa.resample(y, orig_sr=sr, target_sr=SR) if sr != SR else y
|
| 94 |
+
parts = tn["text_parts"] if "text_parts" in tn else [tn["text"]]
|
| 95 |
+
ws = []
|
| 96 |
+
for i, p in enumerate(parts):
|
| 97 |
+
w = trim(tts.synth(p, seed=11 + i, voice=tn.get("voice", "waxal_ibo_5_female")), tts.sr)
|
| 98 |
+
ws += [w, np.zeros(int(tn.get("inner_pause", 0.35) * tts.sr), np.float32)]
|
| 99 |
+
y = np.concatenate(ws[:-1]); return librosa.resample(y, orig_sr=tts.sr, target_sr=SR).astype(np.float32) * 0.9
|
| 100 |
+
|
| 101 |
+
def save(out, name, res, meta):
|
| 102 |
+
os.makedirs(out, exist_ok=True)
|
| 103 |
+
sf.write(f"{out}/{name}.wav", res["mono"], res["sr"]); sf.write(f"{out}/{name}_stereo_userL_agentR.wav", res["stereo"], res["sr"])
|
| 104 |
+
json.dump(dict(meta, turns=res["log"]), open(f"{out}/{name}.json", "w"), ensure_ascii=False, indent=1, default=str)
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
mode, src, out = sys.argv[1], sys.argv[2], sys.argv[3]
|
| 108 |
+
tts = TTS(os.environ.get("AGENT_VOICE", "anchor_male")); resp = Responder(); sim = Sim(tts, resp)
|
| 109 |
+
if mode == "convo":
|
| 110 |
+
for sc in json.load(open(src)):
|
| 111 |
+
resp.style = None
|
| 112 |
+
turns = [dict(t, audio=user_audio(tts, t)) for t in sc["turns"]]
|
| 113 |
+
res = sim.run(turns, sc["name"]); save(out, sc["name"], res, {k: v for k, v in sc.items() if k != "turns"} | dict(user_turns=[{k: v for k, v in t.items() if k != "audio"} for t in turns]))
|
| 114 |
+
else: # eval: single-turn items from the ASR eval list (real held-out audio), each followed by a barge-in at 1.0 s
|
| 115 |
+
R = [json.loads(l) for l in open(src)]; N = int(sys.argv[4]) if len(sys.argv) > 4 else 40
|
| 116 |
+
import random; random.seed(1); R = random.sample(R, min(N, len(R))); allr = []
|
| 117 |
+
for i, r in enumerate(R):
|
| 118 |
+
resp.style = None
|
| 119 |
+
barger = R[(i + 1) % len(R)]
|
| 120 |
+
turns = [dict(wav=r["wav"], label=r["id"], text=r["text"]), dict(wav=barger["wav"], label=barger["id"] + " (barge-in)", text=barger["text"], barge_at=1.0)]
|
| 121 |
+
turns = [dict(t, audio=user_audio(tts, t)) for t in turns]
|
| 122 |
+
res = sim.run(turns, r["id"]); allr.append(dict(id=r["id"], lang=r["lang"], ref=r["text"], turns=res["log"]))
|
| 123 |
+
json.dump(allr, open(f"{out}/eval_turns.json", "w"), ensure_ascii=False, indent=1, default=str)
|
| 124 |
+
print("OFFLINE_DONE")
|
code/respond.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Responder: ASR text -> mix estimate -> LLM candidates -> validator (regenerate up to N rounds) -> tagged reply."""
|
| 2 |
+
import time, re
|
| 3 |
+
import mix as M, validator as V
|
| 4 |
+
from llm import LLM, build_messages
|
| 5 |
+
|
| 6 |
+
class Responder:
|
| 7 |
+
def __init__(self, llm=None, n=3, rounds=2, lid="glotlid", prior_w=0.3, asr=True):
|
| 8 |
+
self.llm = llm or LLM(); self.n, self.rounds, self.lid, self.prior_w = n, rounds, lid, prior_w
|
| 9 |
+
self.asr = asr; self.style = None # running conversation-level mix (user's style)
|
| 10 |
+
|
| 11 |
+
def reply(self, user_text, history=()):
|
| 12 |
+
t0 = time.perf_counter()
|
| 13 |
+
est = M.estimate(user_text, lid=self.lid, prior=self.style, prior_w=self.prior_w if self.style else 0.0, asr=self.asr)
|
| 14 |
+
cur = M.estimate(user_text, lid=self.lid, asr=self.asr)
|
| 15 |
+
self.style = cur["mix"] if self.style is None else {k: 0.5 * self.style.get(k, 0) + 0.5 * cur["mix"].get(k, 0) for k in set(self.style) | set(cur["mix"])}
|
| 16 |
+
target = dict(mix=est["mix"], matrix=est["matrix"], pattern=cur["pattern"])
|
| 17 |
+
desc = M.describe(dict(target, mix=est["mix"]))
|
| 18 |
+
t_mix = time.perf_counter() - t0
|
| 19 |
+
cands, log, t_llm = [], [], 0.0
|
| 20 |
+
chosen, how = None, None
|
| 21 |
+
for rd in range(self.rounds):
|
| 22 |
+
msgs = build_messages(user_text, desc + ("" if rd == 0 else " IMPORTANT: your previous attempt did not follow the language target or the tag format. Follow it exactly."), history)
|
| 23 |
+
outs, dt = self.llm.generate(msgs, n=self.n, temperature=0.7 if rd == 0 else 0.9)
|
| 24 |
+
t_llm += dt
|
| 25 |
+
rs = [V.check(o, target, lid=self.lid, user=user_text) for o in outs]
|
| 26 |
+
cands += rs; log.append(dict(round=rd, llm_s=round(dt, 3), cands=[dict(text=r["text"], ok=r["ok"], why=r["why"], tvd=r["tvd"]) for r in rs]))
|
| 27 |
+
ok = [r for r in rs if r["ok"]]
|
| 28 |
+
if ok:
|
| 29 |
+
chosen = min(ok, key=lambda r: r["score"]); how = "first_round" if rd == 0 else f"regen_round{rd}"
|
| 30 |
+
break
|
| 31 |
+
if chosen is None:
|
| 32 |
+
text, how = V.fallback(cands, target); chosen = V.check(text, target, lid=self.lid); chosen["text"] = text
|
| 33 |
+
return dict(user=user_text, target=target, target_desc=desc, reply=chosen["text"], reply_mix=chosen.get("mix", {}),
|
| 34 |
+
tvd=chosen.get("tvd"), how=how, first_cand_ok=bool(cands and cands[0]["ok"]), any_first_round_ok=any(r["ok"] for r in cands[:self.n]),
|
| 35 |
+
rounds=log, t_mix=round(t_mix, 4), t_llm=round(t_llm, 3), t_total=round(time.perf_counter() - t0, 3))
|
code/scenarios.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{"name": "01_igbo_greetings_english", "desc": "Mostly English with Igbo greetings/closing (ACE's target case). Includes a long turn (backchannel) and a barge-in. User turns are TTS-synthesized (Flash, female Igbo voice).",
|
| 3 |
+
"turns": [
|
| 4 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_ibo_5_female", "text": "<ibo>Ndewo!</ibo> <eng>Good morning. Please, can you help me plan my day? I have a lot to do.</eng>"},
|
| 5 |
+
{"label": "TTS-synthesized user turn (long, mid-turn pause)", "voice": "waxal_ibo_5_female", "text_parts": ["<eng>I need to go to the bank, and then the market,</eng>", "<eng>and in the evening I also want to call my mother.</eng>"], "inner_pause": 0.4},
|
| 6 |
+
{"label": "TTS-synthesized user turn (barge-in 1.5 s into the reply)", "voice": "waxal_ibo_5_female", "text": "<eng>Wait, sorry, the bank closes early today.</eng>", "barge_at": 1.5},
|
| 7 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_ibo_5_female", "text": "<eng>Okay, thank you so much.</eng> <ibo>Daalụ!</ibo>"}]},
|
| 8 |
+
{"name": "02_yoruba_greetings_english", "desc": "Yoruba greetings + English. User turns TTS-synthesized (Flash, female Yoruba voice).",
|
| 9 |
+
"turns": [
|
| 10 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_yor_5_female", "text": "<yor>Ẹ káàárọ̀!</yor> <eng>I want to buy a new phone for my sister.</eng>"},
|
| 11 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_yor_5_female", "text": "<eng>Her budget is about one hundred thousand naira.</eng>"},
|
| 12 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_yor_5_female", "text": "<eng>Okay, that sounds good.</eng> <yor>Ẹ ṣé!</yor>"}]},
|
| 13 |
+
{"name": "03_pidgin_bargein", "desc": "Nigerian Pidgin throughout, with a barge-in. User turns TTS-synthesized (Flash, female voice).",
|
| 14 |
+
"turns": [
|
| 15 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_yor_5_female", "text": "<pcm>How far? Abeg, wetin I fit cook tonight wey no go take long?</pcm>"},
|
| 16 |
+
{"label": "TTS-synthesized user turn (barge-in 1.2 s into the reply)", "voice": "waxal_yor_5_female", "text": "<pcm>No be rice o, I no get rice for house.</pcm>", "barge_at": 1.2},
|
| 17 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_yor_5_female", "text": "<pcm>Okay, na so. Thank you well well.</pcm>"}]},
|
| 18 |
+
{"name": "04_hausa_greetings_english", "desc": "Hausa greeting + English, Hausa closing. User turns TTS-synthesized (Flash, female voice).",
|
| 19 |
+
"turns": [
|
| 20 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_ibo_5_female", "text": "<hau>Sannu, barka da yamma.</hau> <eng>I am travelling to Kano tomorrow.</eng>"},
|
| 21 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_ibo_5_female", "text": "<eng>What should I pack for the weather there?</eng>"},
|
| 22 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_ibo_5_female", "text": "<hau>To, na gode.</hau>"}]},
|
| 23 |
+
{"name": "05_igbo_heavy", "desc": "Igbo-heavier user (shows the weak spot: LLM Igbo beyond phrases). User turns TTS-synthesized (Flash, female Igbo voice).",
|
| 24 |
+
"turns": [
|
| 25 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_ibo_5_female", "text": "<ibo>Kedu? Biko, nyere m aka.</ibo> <eng>I need a taxi to the airport.</eng>"},
|
| 26 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_ibo_5_female", "text": "<ibo>Ee, ọ dị mma.</ibo> <eng>I will leave at six.</eng>"},
|
| 27 |
+
{"label": "TTS-synthesized user turn", "voice": "waxal_ibo_5_female", "text": "<ibo>Daalụ, ka ọ dị.</ibo>"}]},
|
| 28 |
+
{"name": "06_real_waxal_clips", "desc": "User turns are REAL held-out WAXAL test recordings (CC-BY-SA-4.0; read sentences, so the content is not conversational): Pidgin, an English+Igbo concatenation, Hausa.",
|
| 29 |
+
"turns": [
|
| 30 |
+
{"label": "real WAXAL test clip pcm_121", "wav": "/scratch/duplex/asr_eval/wav/pcm_121.wav"},
|
| 31 |
+
{"label": "real WAXAL/FLEURS clips concatenated (eng + ibo), cs_000", "wav": "/scratch/duplex/asr_eval/wav/cs_000.wav"},
|
| 32 |
+
{"label": "real WAXAL test clip hau_081", "wav": "/scratch/duplex/asr_eval/wav/hau_081.wav"}]}
|
| 33 |
+
]
|
code/server.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live full-duplex demo: browser mic (16 kHz PCM over a WebSocket) -> VAD/turn-taking -> streaming ASR (partials) ->
|
| 2 |
+
mix-matched LLM reply + validator -> Flash-DPO TTS streamed back sentence by sentence; barge-in stops playback at once.
|
| 3 |
+
ASR_URL=http://127.0.0.1:8002/asr LLM_BASE=http://127.0.0.1:8001/v1 python server.py (port 7860)"""
|
| 4 |
+
import asyncio, json, os, time, threading, re
|
| 5 |
+
import numpy as np, httpx, uvicorn
|
| 6 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
| 7 |
+
from fastapi.responses import HTMLResponse
|
| 8 |
+
from turn import TurnDetector, FR, SR, FMS
|
| 9 |
+
from respond import Responder
|
| 10 |
+
from tts_engine import TTS
|
| 11 |
+
from offline import BC, trim
|
| 12 |
+
|
| 13 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 14 |
+
ASR_URL = os.environ.get("ASR_URL", "http://127.0.0.1:8002/asr")
|
| 15 |
+
app = FastAPI()
|
| 16 |
+
TTS_ = TTS(os.environ.get("AGENT_VOICE", "anchor_male"))
|
| 17 |
+
LOCK = threading.Lock()
|
| 18 |
+
BCW = {k: trim(TTS_.synth(v, seed=3), TTS_.sr) for k, v in BC.items()}
|
| 19 |
+
acli = httpx.AsyncClient(timeout=30)
|
| 20 |
+
import mix as _M, logging; _M.estimate('warm up', asr=True); logging.getLogger('httpx').setLevel(logging.WARNING)
|
| 21 |
+
|
| 22 |
+
def pcm16(w):
|
| 23 |
+
return (np.clip(w, -1, 1) * 32767).astype(np.int16).tobytes()
|
| 24 |
+
|
| 25 |
+
async def asr(y):
|
| 26 |
+
r = await acli.post(ASR_URL, content=np.ascontiguousarray(y, np.float32).tobytes())
|
| 27 |
+
return r.json()
|
| 28 |
+
|
| 29 |
+
def synth_locked(c, seed):
|
| 30 |
+
with LOCK:
|
| 31 |
+
t = time.perf_counter(); w = TTS_.synth(c, seed=seed); return w, time.perf_counter() - t
|
| 32 |
+
|
| 33 |
+
@app.get("/")
|
| 34 |
+
def index():
|
| 35 |
+
return HTMLResponse(open(f"{HERE}/static/index.html", encoding="utf-8").read())
|
| 36 |
+
|
| 37 |
+
@app.websocket("/ws")
|
| 38 |
+
async def ws(w: WebSocket):
|
| 39 |
+
await w.accept()
|
| 40 |
+
loop = asyncio.get_running_loop()
|
| 41 |
+
td = TurnDetector(eot_ms=int(os.environ.get("EOT_MS", 600))); resp = Responder(); hist = []
|
| 42 |
+
st = dict(play_until=0.0, task=None, gen=0, last_partial=0.0, partial_busy=False, lang="eng")
|
| 43 |
+
pending = bytearray()
|
| 44 |
+
from tts_engine import chunk_tagged
|
| 45 |
+
|
| 46 |
+
async def partial(y):
|
| 47 |
+
st["partial_busy"] = True
|
| 48 |
+
try:
|
| 49 |
+
r = await asr(y); await w.send_json({"type": "partial", "text": r["text"], "asr_ms": r["ms"]})
|
| 50 |
+
except Exception: pass
|
| 51 |
+
st["partial_busy"] = False
|
| 52 |
+
|
| 53 |
+
gate = {} # gen -> asyncio.Event set at end-of-turn (speculative responses start at spec_ms of silence)
|
| 54 |
+
|
| 55 |
+
async def speak(audio, speech_end_wall, gen):
|
| 56 |
+
"""ASR -> mix/LLM/validator -> first TTS chunk are computed speculatively (from spec_ms of silence); nothing is sent
|
| 57 |
+
until end-of-turn opens the gate, and the whole response is dropped if the user resumes (gen changes)."""
|
| 58 |
+
r = await asr(audio); text = r["text"].strip()
|
| 59 |
+
if len(text.split()) == 0 or st["gen"] != gen:
|
| 60 |
+
return
|
| 61 |
+
rep = await loop.run_in_executor(None, resp.reply, text, list(hist))
|
| 62 |
+
chunks = chunk_tagged(rep["reply"])
|
| 63 |
+
if st["gen"] != gen or not chunks:
|
| 64 |
+
return
|
| 65 |
+
wav0, ds0 = await loop.run_in_executor(None, synth_locked, chunks[0], 0)
|
| 66 |
+
if gen in gate:
|
| 67 |
+
await gate[gen].wait()
|
| 68 |
+
if st["gen"] != gen:
|
| 69 |
+
return
|
| 70 |
+
st["committed"] = gen; st["lang"] = rep["target"]["matrix"]
|
| 71 |
+
await w.send_json({"type": "final", "text": text, "asr_ms": r["ms"]})
|
| 72 |
+
await w.send_json({"type": "reply", "text": rep["reply"], "target": rep["target"], "reply_mix": rep["reply_mix"], "how": rep["how"],
|
| 73 |
+
"llm_s": rep["t_llm"]})
|
| 74 |
+
played = []
|
| 75 |
+
for i, c in enumerate(chunks):
|
| 76 |
+
if i == 0:
|
| 77 |
+
wav, ds = wav0, ds0
|
| 78 |
+
else:
|
| 79 |
+
wav, ds = await loop.run_in_executor(None, synth_locked, c, i)
|
| 80 |
+
if st["gen"] != gen: break
|
| 81 |
+
await w.send_bytes(pcm16(wav))
|
| 82 |
+
now = time.time(); st["play_until"] = max(st["play_until"], now) + len(wav) / TTS_.sr; played.append(c)
|
| 83 |
+
if i == 0:
|
| 84 |
+
await w.send_json({"type": "metrics", "latency_s": round(now - speech_end_wall, 3), "asr_ms": r["ms"], "llm_s": rep["t_llm"],
|
| 85 |
+
"tts_first_s": round(ds, 3), "eot_ms": round(td.eot_f * FMS * 1000)})
|
| 86 |
+
hist.append((text, " ".join(played) + ("" if len(played) == len(chunks) else " [interrupted]")))
|
| 87 |
+
if st.get("committed") == gen:
|
| 88 |
+
st["committed"] = None
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
while True:
|
| 92 |
+
msg = await w.receive()
|
| 93 |
+
if msg.get("type") == "websocket.disconnect": break
|
| 94 |
+
if msg.get("text"):
|
| 95 |
+
d = json.loads(msg["text"])
|
| 96 |
+
if d.get("type") == "reset": hist.clear(); resp.style = None; await w.send_json({"type": "info", "text": "conversation reset"})
|
| 97 |
+
continue
|
| 98 |
+
if not msg.get("bytes"): continue
|
| 99 |
+
pending += msg["bytes"]
|
| 100 |
+
while len(pending) >= FR * 2:
|
| 101 |
+
fr = np.frombuffer(bytes(pending[:FR * 2]), np.int16).astype(np.float32) / 32768; del pending[:FR * 2]
|
| 102 |
+
busy = time.time() < st["play_until"] or st.get("committed") is not None
|
| 103 |
+
for name, info in td.feed(fr, agent_busy=busy):
|
| 104 |
+
if name == "start":
|
| 105 |
+
await w.send_json({"type": "vad", "speech": True})
|
| 106 |
+
elif name == "barge":
|
| 107 |
+
st["gen"] += 1; st["play_until"] = 0.0; st["committed"] = None
|
| 108 |
+
await w.send_json({"type": "stop", "reason": "barge-in"})
|
| 109 |
+
elif name == "backchannel":
|
| 110 |
+
await w.send_json({"type": "backchannel"}); await w.send_bytes(pcm16(0.8 * BCW.get(st["lang"], BCW["eng"])))
|
| 111 |
+
elif name == "spec":
|
| 112 |
+
st["gen"] += 1; g = st["gen"]; gate[g] = asyncio.Event()
|
| 113 |
+
speech_end_wall = time.time() - (td.t - info["speech_end"])
|
| 114 |
+
st["task"] = asyncio.create_task(speak(info["audio"], speech_end_wall, g))
|
| 115 |
+
elif name == "resume":
|
| 116 |
+
if st["gen"] in gate: gate[st["gen"]].set() # release the waiting speculative task; it sees the new gen and exits
|
| 117 |
+
st["gen"] += 1 # user kept talking: drop the speculative response
|
| 118 |
+
elif name == "eot":
|
| 119 |
+
await w.send_json({"type": "vad", "speech": False, "speech_s": round(info["speech_s"], 2)})
|
| 120 |
+
if st["gen"] in gate:
|
| 121 |
+
gate[st["gen"]].set()
|
| 122 |
+
else:
|
| 123 |
+
st["gen"] += 1
|
| 124 |
+
speech_end_wall = time.time() - (td.t - info["speech_end"])
|
| 125 |
+
st["task"] = asyncio.create_task(speak(info["audio"], speech_end_wall, st["gen"]))
|
| 126 |
+
if td.in_speech and td.t - st["last_partial"] >= 0.64 and not st["partial_busy"]:
|
| 127 |
+
st["last_partial"] = td.t; asyncio.create_task(partial(td.partial_audio()))
|
| 128 |
+
except WebSocketDisconnect:
|
| 129 |
+
pass
|
| 130 |
+
|
| 131 |
+
if __name__ == "__main__":
|
| 132 |
+
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)), log_level="warning")
|
code/static/index.html
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 2 |
+
<title>Dynamic Accent Duplex (experimental)</title>
|
| 3 |
+
<style>body{font-family:system-ui,sans-serif;max-width:760px;margin:24px auto;padding:0 14px;color:#222}
|
| 4 |
+
button{font-size:16px;padding:10px 18px;margin-right:8px;border-radius:8px;border:1px solid #888;background:#f4f4f4;cursor:pointer}
|
| 5 |
+
#log{border:1px solid #ddd;border-radius:8px;padding:10px;height:420px;overflow:auto;font-size:14px;background:#fafafa}
|
| 6 |
+
.u{color:#0a4}.a{color:#036}.p{color:#999;font-style:italic}.m{color:#a60;font-size:12px}.s{color:#c00}
|
| 7 |
+
#dot{display:inline-block;width:12px;height:12px;border-radius:6px;background:#ccc;vertical-align:middle;margin-left:6px}
|
| 8 |
+
small{color:#666}</style></head><body>
|
| 9 |
+
<h2>Dynamic Accent Duplex <small>experimental cascaded voice agent</small></h2>
|
| 10 |
+
<p>Speak English, Nigerian Pidgin, Igbo, Yoruba or Hausa, mixed however you like. Ada answers in the same mix. You can interrupt her.
|
| 11 |
+
<b>Use headphones</b> (otherwise her voice can trigger barge-in).</p>
|
| 12 |
+
<button id="go">Start</button><button id="rs">Reset conversation</button> mic<span id="dot"></span>
|
| 13 |
+
<div id="log"></div>
|
| 14 |
+
<p><small>Pipeline: Silero VAD → Omnilingual ASR (CTC) → per-word language ID → Qwen3 LLM with mix validator → Dynamic Accent Flash TTS. Research demo; replies can be wrong, and Igbo/Yoruba/Hausa beyond simple phrases are weak.</small></p>
|
| 15 |
+
<script>
|
| 16 |
+
const log=(c,t)=>{const d=document.getElementById('log');const e=document.createElement('div');e.className=c;e.textContent=t;d.appendChild(e);d.scrollTop=1e9;return e};
|
| 17 |
+
const strip=t=>t.replace(/<\/?(eng|ibo|yor|hau|pcm)>/g,m=>m.startsWith('</')?'':'['+m.slice(1,-1)+'] ');
|
| 18 |
+
let ws,ctx,next=0,srcs=[],partialEl=null;
|
| 19 |
+
function play(buf){const i16=new Int16Array(buf);const f=new Float32Array(i16.length);for(let i=0;i<i16.length;i++)f[i]=i16[i]/32768;
|
| 20 |
+
const b=ctx.createBuffer(1,f.length,24000);b.copyToChannel(f,0);const s=ctx.createBufferSource();s.buffer=b;s.connect(ctx.destination);
|
| 21 |
+
const t=Math.max(ctx.currentTime+0.02,next);s.start(t);next=t+b.duration;srcs.push(s);s.onended=()=>{srcs=srcs.filter(x=>x!==s)}}
|
| 22 |
+
function stopAll(){srcs.forEach(s=>{try{s.stop()}catch(e){}});srcs=[];next=0}
|
| 23 |
+
document.getElementById('rs').onclick=()=>{ws&&ws.send(JSON.stringify({type:'reset'}));document.getElementById('log').innerHTML=''};
|
| 24 |
+
document.getElementById('go').onclick=async()=>{
|
| 25 |
+
ctx=new AudioContext({sampleRate:16000});
|
| 26 |
+
const st=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true,channelCount:1}});
|
| 27 |
+
const code=`class P extends AudioWorkletProcessor{constructor(){super();this.b=new Float32Array(512);this.n=0}
|
| 28 |
+
process(i){const x=i[0][0];if(!x)return true;for(let k=0;k<x.length;k++){this.b[this.n++]=x[k];if(this.n==512){const o=new Int16Array(512);
|
| 29 |
+
for(let j=0;j<512;j++){o[j]=Math.max(-32768,Math.min(32767,this.b[j]*32767))}this.port.postMessage(o.buffer,[o.buffer]);this.n=0}}return true}}
|
| 30 |
+
registerProcessor('p',P)`;
|
| 31 |
+
await ctx.audioWorklet.addModule(URL.createObjectURL(new Blob([code],{type:'application/javascript'})));
|
| 32 |
+
const node=new AudioWorkletNode(ctx,'p');ctx.createMediaStreamSource(st).connect(node);
|
| 33 |
+
ws=new WebSocket((location.protocol==='https:'?'wss':'ws')+'://'+location.host+location.pathname.replace(/\/$/,'')+'/ws');ws.binaryType='arraybuffer';
|
| 34 |
+
node.port.onmessage=e=>{if(ws.readyState===1)ws.send(e.data)};
|
| 35 |
+
ws.onopen=()=>log('m','connected - start talking');
|
| 36 |
+
ws.onmessage=e=>{if(typeof e.data!=='string'){play(e.data);return}const d=JSON.parse(e.data);
|
| 37 |
+
if(d.type==='vad'){document.getElementById('dot').style.background=d.speech?'#0a4':'#ccc'}
|
| 38 |
+
else if(d.type==='partial'){if(!partialEl)partialEl=log('p','');partialEl.textContent='… '+d.text}
|
| 39 |
+
else if(d.type==='final'){if(partialEl){partialEl.remove();partialEl=null}log('u','You: '+d.text)}
|
| 40 |
+
else if(d.type==='reply'){log('a','Ada: '+strip(d.text));log('m','target mix '+JSON.stringify(d.target.mix)+' '+(d.target.pattern||[]).join(',')+' | reply mix '+JSON.stringify(d.reply_mix)+' | '+d.how)}
|
| 41 |
+
else if(d.type==='metrics'){log('m','latency end-of-speech→first audio '+d.latency_s+' s (EOT wait '+d.eot_ms+' ms, ASR '+d.asr_ms+' ms, LLM '+d.llm_s+' s, TTS first chunk '+d.tts_first_s+' s)')}
|
| 42 |
+
else if(d.type==='stop'){stopAll();log('s','[barge-in: Ada stopped]')}
|
| 43 |
+
else if(d.type==='backchannel'){log('m','(backchannel)')}
|
| 44 |
+
else if(d.type==='info'){log('m',d.text)}};
|
| 45 |
+
ws.onclose=()=>log('s','disconnected');document.getElementById('go').disabled=true};
|
| 46 |
+
</script></body></html>
|
code/tagger.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Dynamic Accent auto-tagger: word/span-level language ID for untagged mixed Nigerian text.
|
| 3 |
+
|
| 4 |
+
Labels: eng (English), pcm (Nigerian Pidgin), ibo (Igbo), yor (Yoruba), hau (Hausa).
|
| 5 |
+
Output is the tagged text infer.py expects, e.g.
|
| 6 |
+
"omo, I'm very excited" -> "<pcm>omo,</pcm> <eng>I'm very excited</eng>"
|
| 7 |
+
|
| 8 |
+
How it decides (rule-based, no model needed):
|
| 9 |
+
1. letters: Yoruba-only (ẹ ṣ, tone-marked n/m), Igbo-only (ị ụ ṅ), Hausa-only (ɓ ɗ ƙ ƴ), shared ọ;
|
| 10 |
+
2. lexicons of frequent function words / greetings per language (diacritics folded for lookup);
|
| 11 |
+
3. Pidgin markers ("dey", "abi", "omo", "wetin", "abeg", "una", "no go", "e don", "know say", pronoun + "go" + verb ...):
|
| 12 |
+
a clause of plain-ASCII words that contains a Pidgin marker becomes Pidgin as a whole;
|
| 13 |
+
4. clause context: ambiguous words ("na", "o", "ka", "ya", "ma", "ni" ...) and unknown words take the language of the
|
| 14 |
+
clause they sit in, then of the neighbouring clause; plain ASCII with no evidence falls back to English.
|
| 15 |
+
5. optional: --lid glotlid adds clause-level scores from GlotLID v3 (cis-lmu/glotlid, fastText, Apache-2.0 + notices,
|
| 16 |
+
1.7 GB download, needs `pip install fasttext`), mainly to separate untagged Igbo/Yoruba/Hausa written without
|
| 17 |
+
diacritics from English.
|
| 18 |
+
|
| 19 |
+
Overrides: lang_map {"omo": "pcm"} forces a word; force_lang renders everything in one language.
|
| 20 |
+
Always print the tagged text and let the user fix it: the tagger is a convenience, not ground truth.
|
| 21 |
+
|
| 22 |
+
python tagger.py "Omo, this traffic no be here o. I go call you later."
|
| 23 |
+
python tagger.py --lang_map "omo=pcm,sha=pcm" "..."
|
| 24 |
+
"""
|
| 25 |
+
import argparse, json, re, sys, unicodedata
|
| 26 |
+
|
| 27 |
+
LANGS = ["eng", "pcm", "ibo", "yor", "hau"]
|
| 28 |
+
NG = ("ibo", "yor", "hau")
|
| 29 |
+
TAGGED = re.compile(r"<(eng|ibo|hau|yor|pcm)>")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def fold(w):
|
| 33 |
+
"""lowercase, NFD, drop every combining mark (tones, dot below, dot above), map hooked letters."""
|
| 34 |
+
w = unicodedata.normalize("NFD", w.lower())
|
| 35 |
+
w = "".join(c for c in w if not unicodedata.combining(c))
|
| 36 |
+
return w.translate(str.maketrans({"ɓ": "b", "ɗ": "d", "ƙ": "k", "ƴ": "y", "’": "'", "ʼ": "'", "‘": "'"}))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _set(s):
|
| 40 |
+
return {fold(x) for x in s.split()}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _exact(s):
|
| 44 |
+
return {unicodedata.normalize("NFC", x.lower()) for x in s.split()}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# --- lexicons (frequent, high-precision words; folded). Words shared across languages are listed in each.
|
| 48 |
+
_RAW = {
|
| 49 |
+
"ibo": """
|
| 50 |
+
bụ nke ndị ihe ụlọ mmiri kedu ndewo nụ ụmụ ụmụnna nna nne anyị unu gị ha ọ ya ka ma
|
| 51 |
+
nwere ga ji dị ọma biko daalụ dalu ụtọ obi ike nri ego akwụkwọ ọrụ maka ebe otu ọzọ ugbu
|
| 52 |
+
a m n' na mere meela kwa kwesịrị ịga ịbịa bịa gaa nyere aka mmadụ obodo nwanne nwa nwoke nwanyị
|
| 53 |
+
ụbọchị echi taa ụnyaahụ abalị ututu ehihie mgbede oge afọ ọnwa izu ukwuu nnukwu ntakịrị
|
| 54 |
+
mana ọbụna ọbụla ruo site gbasara ebee onye gịnị olee ole kpọmkwem eziokwu nso anọ abụọ atọ
|
| 55 |
+
ise isii asaa asatọ itoolu iri mbụ zuru ezu ahụ ahụla ịhụ hụ chere chọrọ chọ mara amaghị
|
| 56 |
+
nọ nọrọ bịara gara kwuru kwuo sị siri esi na-esi na-eme eme mee eji ogologo ọkụ ụzọ ahịa ụka
|
| 57 |
+
ọnụ ụtụtụ ka ọ dị mma mma nnọọ kpọọ zụta re ree rie riri ụmụaka ezinụlọ chi chukwu ndụ
|
| 58 |
+
""",
|
| 59 |
+
"yor": """
|
| 60 |
+
ni ti si sí àti àwọn mo o wa wọn kò ṣé jẹ́ ọjọ́ ẹ káàbọ̀ ilé ìlú ọmọ púpọ̀ dáadáa bẹ́ẹ̀ ni
|
| 61 |
+
náà yìí kan mi wa yín rẹ̀ rẹ tirẹ̀ fún láti pé ń máa yóò ó a jẹ ṣe lọ wá rí fẹ́ mọ̀ sọ gbọ́
|
| 62 |
+
ẹ̀kọ́ iṣẹ́ owó ọjà òní ọ̀la àná ìpàdé padà ilé-ìwé ọjọ́ ajé ìsinmi alẹ́ àárọ̀ káàárọ̀ káàsán
|
| 63 |
+
káalẹ́ ẹ kú ṣé gan an pàdé ìyá bàbá ẹ̀gbọ́n àbúrò ọ̀rẹ́ ènìyàn èèyàn àgbà ọba ìjọba
|
| 64 |
+
nítorí ṣùgbọ́n tàbí bí kí kì ìgbà ibi ibo kílódé báwo kíni ta tani mélòó ọ̀kan méjì mẹ́ta
|
| 65 |
+
mẹ́rin márùn-ún ẹ̀ẹ̀kan dúpẹ́ ẹ ṣeun ẹ̀ jọ̀ọ́ jọ̀ọ́ oúnjẹ omi ilẹ̀ ojú ọwọ́ ẹsẹ̀
|
| 66 |
+
""",
|
| 67 |
+
"hau": """
|
| 68 |
+
da ba ya ta ka ki su mu na ne ce ina yaya barka kuke don haka za idan mai wannan wancan yana
|
| 69 |
+
tana suna muna kuna sannu lafiya kwana aiki gobe yau jiya ruwa sai amma kuma shi ita nan
|
| 70 |
+
wani wata abin zan zai zata zamu mutane gida kudi gode sosai tafi fita zuwa laima yamma safe
|
| 71 |
+
rana dare mako shekara wata yanzu kai ke ku mun sun ban bai bata sani so iya yi yin
|
| 72 |
+
farashin tumatur tashi kasuwa abinci ruwan sha babu akwai kamar saboda domin tare har tun
|
| 73 |
+
daga cikin waje gaba baya sama kasa me wane wace ina yaushe nawa daya biyu uku hudu biyar
|
| 74 |
+
""",
|
| 75 |
+
"pcm": """
|
| 76 |
+
dey abi omo wetin abeg una wahala sabi oga sef sha oya jare ehn ehen shey kpele pikin
|
| 77 |
+
chop comot waka belle palava wan wey dem don am siddon gist gbam yawa kampe sharp sharp
|
| 78 |
+
nawa wahala kolo japa ginger vex dash ogbonge padi paddy shine eye
|
| 79 |
+
""",
|
| 80 |
+
"eng": """
|
| 81 |
+
the a an and or but if then so because of to in on at for with from by about as into over
|
| 82 |
+
after before between through during without under again further here there when where why how
|
| 83 |
+
all any both each few more most other some such no nor not only own same than too very can
|
| 84 |
+
will just should now i me my we our you your he him his she her it its they them their what
|
| 85 |
+
which who whom this that these those am is are was were be been being have has had having do
|
| 86 |
+
does did doing would could might must shall may please thank thanks yes hello hi good morning
|
| 87 |
+
evening afternoon night today tomorrow yesterday week year time day people man woman child
|
| 88 |
+
children team meeting report school market road weather rain safe everyone everybody
|
| 89 |
+
new old big small great right left back home work job money food water house car family
|
| 90 |
+
friend friends know think say said tell told make made take took get got give gave go went
|
| 91 |
+
come came see saw look want need like love help call send sent finish start stop keep let put
|
| 92 |
+
read write talk speak discuss three two one four five six seven eight nine ten first second
|
| 93 |
+
last next soon late early ready sure also still really very much many lot thing things way
|
| 94 |
+
out up down off well done going coming doing nothing something everything anything model
|
| 95 |
+
""",
|
| 96 |
+
}
|
| 97 |
+
LEX = {l: _set(v) for l, v in _RAW.items()} # folded (diacritics dropped) for lookup
|
| 98 |
+
LEX_EXACT = {l: _exact(v) for l, v in _RAW.items()} # as written
|
| 99 |
+
_N_LEX = {}
|
| 100 |
+
for _l, _v in LEX.items():
|
| 101 |
+
for _w in _v:
|
| 102 |
+
_N_LEX[_w] = _N_LEX.get(_w, 0) + 1 # >1 = word shared by several languages (na, o, ka, ya ...)
|
| 103 |
+
# words that are Pidgin markers on their own (strong evidence)
|
| 104 |
+
PCM_STRONG = _set("dey abi omo wetin abeg una wahala sabi sef oya jare shey pikin chop comot waka palava wan wey dem siddon vex japa ehn ehen")
|
| 105 |
+
PCM_BIGRAMS = {("no", "go"), ("no", "be"), ("no", "fit"), ("no", "dey"), ("e", "don"), ("e", "dey"), ("e", "no"),
|
| 106 |
+
("know", "say"), ("tell", "am"), ("well", "well"), ("how", "far"), ("how", "body"), ("na", "so"),
|
| 107 |
+
("make", "we"), ("make", "i"), ("make", "una"), ("make", "dem"), ("i", "no"), ("we", "no"),
|
| 108 |
+
("you", "no"), ("dem", "no"), ("no", "easy"), ("no", "forget"), ("no", "vex"), ("fit", "heavy"),
|
| 109 |
+
("don", "come"), ("don", "go"), ("for", "inside"), ("una", "well"), ("e", "go"), ("na", "you"),
|
| 110 |
+
("na", "me"), ("na", "im"), ("na", "dem"), ("na", "una"), ("give", "you"), ("lie", "give")}
|
| 111 |
+
PRON = _set("i we you dem una e im him she he they")
|
| 112 |
+
NOT_VERB_AFTER_GO = _set("to home there back out away on in up down off ahead")
|
| 113 |
+
PCM_AMBIG_END = _set("o") # clause-final particle "o" in an ASCII clause -> Pidgin evidence
|
| 114 |
+
|
| 115 |
+
YOR_CH = set("ẹṣ") # after NFC lowercase; also e/s + U+0323
|
| 116 |
+
IBO_CH = set("ịụṅ")
|
| 117 |
+
HAU_CH = set("ɓɗƙƴ")
|
| 118 |
+
WORD = re.compile(r"[^\W\d_](?:[^\W\d_]|['’ʼ\-\u0300-\u036f])*", re.U)
|
| 119 |
+
CLAUSE_END = re.compile(r"[,;:.?!]")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def char_scores(w):
|
| 123 |
+
s = dict.fromkeys(LANGS, 0.0)
|
| 124 |
+
nfd = unicodedata.normalize("NFD", w.lower())
|
| 125 |
+
base = None
|
| 126 |
+
for c in nfd:
|
| 127 |
+
if unicodedata.combining(c):
|
| 128 |
+
if c == "\u0323": # dot below
|
| 129 |
+
if base in "es":
|
| 130 |
+
s["yor"] += 3
|
| 131 |
+
elif base in "iu":
|
| 132 |
+
s["ibo"] += 3
|
| 133 |
+
elif base == "o":
|
| 134 |
+
s["yor"] += 1; s["ibo"] += 1
|
| 135 |
+
elif c == "\u0307" and base == "n":
|
| 136 |
+
s["ibo"] += 3
|
| 137 |
+
elif c in "\u0300\u0301\u0304": # tones
|
| 138 |
+
if base in "nm":
|
| 139 |
+
s["yor"] += 2
|
| 140 |
+
else:
|
| 141 |
+
s["yor"] += 0.8; s["ibo"] += 0.3
|
| 142 |
+
continue
|
| 143 |
+
base = c
|
| 144 |
+
if c in HAU_CH:
|
| 145 |
+
s["hau"] += 4
|
| 146 |
+
if not any(s.values()) and re.fullmatch(r"[a-z'\-]+", fold(w)):
|
| 147 |
+
s["ascii"] = 1
|
| 148 |
+
return s
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def word_scores(w):
|
| 152 |
+
s = char_scores(w)
|
| 153 |
+
ascii_ = s.pop("ascii", 0)
|
| 154 |
+
f = fold(w)
|
| 155 |
+
parts = [f] + (f.split("-") if "-" in f else [])
|
| 156 |
+
if f.startswith("n'") or re.match(r"^na-[a-z]", f):
|
| 157 |
+
s["ibo"] += 3
|
| 158 |
+
ex = unicodedata.normalize("NFC", w.lower())
|
| 159 |
+
for p in parts:
|
| 160 |
+
amb = 0.5 if _N_LEX.get(p, 0) > 1 else 1.0 # shared words: let the clause decide
|
| 161 |
+
for l in LANGS:
|
| 162 |
+
if p in LEX[l]:
|
| 163 |
+
w_ = 1.5 if (p == f and ex in LEX_EXACT[l]) or ascii_ and p in LEX_EXACT[l] else 0.7
|
| 164 |
+
s[l] += amb * (w_ if l != "pcm" else 1.0)
|
| 165 |
+
if f in PCM_STRONG and ascii_: # Pidgin markers are plain ASCII ("ọmọ" is Yoruba)
|
| 166 |
+
s["pcm"] += 4
|
| 167 |
+
# orthography hints (weak)
|
| 168 |
+
if ascii_:
|
| 169 |
+
if re.search(r"(th|wh|ck|tion|ing$|ould$|ght)", f):
|
| 170 |
+
s["eng"] += 1
|
| 171 |
+
if re.match(r"^(nw|kw|gw|mm|nn|nk|ng[a-z])", f) and f not in LEX["eng"]:
|
| 172 |
+
s["ibo"] += 0.7
|
| 173 |
+
if re.search(r"(ts|^ky|^'y|aa|ii|uu)", f) and f not in LEX["eng"]:
|
| 174 |
+
s["hau"] += 0.5
|
| 175 |
+
return s, bool(ascii_)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def tokenize(text):
|
| 179 |
+
"""-> list of (start, end, word) for words; everything else is glue."""
|
| 180 |
+
return [(m.start(), m.end(), m.group(0)) for m in WORD.finditer(text)]
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def clauses(text, toks):
|
| 184 |
+
"""clause index for each word: a new clause starts after , ; : . ? !"""
|
| 185 |
+
ends = [m.start() for m in CLAUSE_END.finditer(text)]
|
| 186 |
+
out, k = [], 0
|
| 187 |
+
for a, b, w in toks:
|
| 188 |
+
while k < len(ends) and ends[k] < a:
|
| 189 |
+
k += 1
|
| 190 |
+
out.append(k)
|
| 191 |
+
return out
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _norm(d):
|
| 195 |
+
t = sum(max(0.0, v) for v in d.values())
|
| 196 |
+
return {k: (max(0.0, v) / t if t else 0.0) for k, v in d.items()}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class Tagger:
|
| 200 |
+
def __init__(self, lid=None, lang_map=None):
|
| 201 |
+
self.lang_map = {fold(k): v for k, v in (lang_map or {}).items()}
|
| 202 |
+
self.lid = None
|
| 203 |
+
if lid == "glotlid":
|
| 204 |
+
import fasttext
|
| 205 |
+
from huggingface_hub import hf_hub_download
|
| 206 |
+
self.lid = fasttext.load_model(hf_hub_download("cis-lmu/glotlid", "model.bin"))
|
| 207 |
+
|
| 208 |
+
def _lid_scores(self, text):
|
| 209 |
+
labs, ps = self.lid.predict(text.replace("\n", " "), k=20)
|
| 210 |
+
m = {"__label__eng_Latn": "eng", "__label__pcm_Latn": "pcm", "__label__ibo_Latn": "ibo",
|
| 211 |
+
"__label__yor_Latn": "yor", "__label__hau_Latn": "hau"}
|
| 212 |
+
s = dict.fromkeys(LANGS, 0.0)
|
| 213 |
+
for l, p in zip(labs, ps):
|
| 214 |
+
if l in m:
|
| 215 |
+
s[m[l]] += float(p)
|
| 216 |
+
return s
|
| 217 |
+
|
| 218 |
+
def word_langs(self, text):
|
| 219 |
+
toks = tokenize(text)
|
| 220 |
+
if not toks:
|
| 221 |
+
return toks, []
|
| 222 |
+
cl = clauses(text, toks)
|
| 223 |
+
n = len(toks)
|
| 224 |
+
sc, asc = zip(*[word_scores(w) for _, _, w in toks])
|
| 225 |
+
sc = [dict(s) for s in sc]
|
| 226 |
+
f = [fold(w) for _, _, w in toks]
|
| 227 |
+
# Pidgin bigrams and "pronoun go verb"
|
| 228 |
+
for i in range(n - 1):
|
| 229 |
+
if cl[i] != cl[i + 1]:
|
| 230 |
+
continue
|
| 231 |
+
if (f[i], f[i + 1]) in PCM_BIGRAMS:
|
| 232 |
+
sc[i]["pcm"] += 2.5; sc[i + 1]["pcm"] += 2.5
|
| 233 |
+
if i + 2 < n and f[i] in PRON and f[i + 1] == "go" and cl[i + 2] == cl[i] and f[i + 2] not in NOT_VERB_AFTER_GO \
|
| 234 |
+
and f[i + 2] in LEX["eng"] | LEX["pcm"]:
|
| 235 |
+
for j in (i, i + 1, i + 2):
|
| 236 |
+
sc[j]["pcm"] += 2.5
|
| 237 |
+
# clause-level evidence
|
| 238 |
+
C = sorted(set(cl))
|
| 239 |
+
cs = {c: dict.fromkeys(LANGS, 0.0) for c in C}
|
| 240 |
+
for i in range(n):
|
| 241 |
+
for l in LANGS:
|
| 242 |
+
cs[cl[i]][l] += sc[i][l]
|
| 243 |
+
# clause-final "o" in an ASCII clause = Pidgin particle (Yoruba "o" clauses carry other Yoruba evidence)
|
| 244 |
+
for i in range(n):
|
| 245 |
+
last = i == n - 1 or cl[i + 1] != cl[i]
|
| 246 |
+
if last and f[i] in PCM_AMBIG_END and asc[i]:
|
| 247 |
+
others = [j for j in range(n) if cl[j] == cl[i] and j != i]
|
| 248 |
+
if others and all(asc[j] for j in others) and cs[cl[i]]["yor"] < 2:
|
| 249 |
+
sc[i]["pcm"] += 3; cs[cl[i]]["pcm"] += 3
|
| 250 |
+
if self.lid is not None:
|
| 251 |
+
for c in C:
|
| 252 |
+
ws = [toks[i][2] for i in range(n) if cl[i] == c]
|
| 253 |
+
for l, v in self._lid_scores(" ".join(ws)).items():
|
| 254 |
+
cs[c][l] += 2.0 * v
|
| 255 |
+
out = []
|
| 256 |
+
for i in range(n):
|
| 257 |
+
c = cl[i]
|
| 258 |
+
ctx = _norm(cs[c])
|
| 259 |
+
nb = dict.fromkeys(LANGS, 0.0) # neighbouring clauses, weaker
|
| 260 |
+
for d in (-1, 1):
|
| 261 |
+
if c + d in cs:
|
| 262 |
+
for l, v in _norm(cs[c + d]).items():
|
| 263 |
+
nb[l] += 0.3 * v
|
| 264 |
+
tot = {l: sc[i][l] + 1.5 * ctx[l] + nb[l] for l in LANGS}
|
| 265 |
+
# plain-ASCII words in a clause without Nigerian-language evidence: English or Pidgin
|
| 266 |
+
if asc[i] and max(sc[i][l] for l in NG) < 1.0:
|
| 267 |
+
ng_ctx = max(cs[c][l] for l in NG)
|
| 268 |
+
if ng_ctx < 1.5 * max(cs[c]["eng"], cs[c]["pcm"], 0.1):
|
| 269 |
+
pc = cs[c]["pcm"] >= 2.5
|
| 270 |
+
out.append("pcm" if pc else ("eng" if sc[i]["pcm"] < 4 else "pcm"))
|
| 271 |
+
continue
|
| 272 |
+
best = max(tot, key=tot.get)
|
| 273 |
+
if tot[best] <= 0:
|
| 274 |
+
best = "eng"
|
| 275 |
+
out.append(best)
|
| 276 |
+
for i in range(n): # user overrides
|
| 277 |
+
if f[i] in self.lang_map:
|
| 278 |
+
out[i] = self.lang_map[f[i]]
|
| 279 |
+
return toks, out
|
| 280 |
+
|
| 281 |
+
def tag(self, text, force_lang=None):
|
| 282 |
+
text = unicodedata.normalize("NFC", text.strip())
|
| 283 |
+
if TAGGED.search(text):
|
| 284 |
+
return text # already tagged: leave alone
|
| 285 |
+
if force_lang:
|
| 286 |
+
return f"<{force_lang}>{text}</{force_lang}>"
|
| 287 |
+
toks, langs = self.word_langs(text)
|
| 288 |
+
if not toks:
|
| 289 |
+
return f"<eng>{text}</eng>"
|
| 290 |
+
# spans: consecutive same-language words; glue (spaces/punct) stays with the preceding word
|
| 291 |
+
spans, cur_l, a = [], langs[0], 0
|
| 292 |
+
for i in range(1, len(toks)):
|
| 293 |
+
if langs[i] != cur_l:
|
| 294 |
+
b = toks[i][0]
|
| 295 |
+
seg = text[a:b]
|
| 296 |
+
spans.append((cur_l, seg))
|
| 297 |
+
cur_l, a = langs[i], b
|
| 298 |
+
spans.append((cur_l, text[a:]))
|
| 299 |
+
lead = text[:toks[0][0]]
|
| 300 |
+
out = []
|
| 301 |
+
for k, (l, s) in enumerate(spans):
|
| 302 |
+
s2 = (lead + s) if k == 0 else s
|
| 303 |
+
core = s2.rstrip()
|
| 304 |
+
out.append(f"<{l}>{core}</{l}>")
|
| 305 |
+
return " ".join(out)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def parse_map(s):
|
| 309 |
+
m = {}
|
| 310 |
+
for kv in (s or "").split(","):
|
| 311 |
+
if "=" in kv:
|
| 312 |
+
k, v = kv.split("=", 1)
|
| 313 |
+
v = v.strip()
|
| 314 |
+
if v not in LANGS:
|
| 315 |
+
raise ValueError(f"--lang_map: unknown language '{v}' (use {LANGS})")
|
| 316 |
+
m[k.strip()] = v
|
| 317 |
+
return m
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def spans_of(tagged):
|
| 321 |
+
return [(m.group(1), m.group(2)) for m in re.finditer(r"<(eng|ibo|hau|yor|pcm)>(.*?)</\1>", tagged, re.S)]
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def word_labels(tagged):
|
| 325 |
+
"""gold/pred word-level labels from a tagged string."""
|
| 326 |
+
out = []
|
| 327 |
+
for l, s in spans_of(tagged):
|
| 328 |
+
out += [(fold(w), l) for _, _, w in tokenize(s)]
|
| 329 |
+
return out
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def strip_tags(tagged):
|
| 333 |
+
return re.sub(r"\s+", " ", re.sub(r"</?(eng|ibo|hau|yor|pcm)>", "", tagged)).strip()
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def main():
|
| 337 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 338 |
+
ap.add_argument("text")
|
| 339 |
+
ap.add_argument("--lang_map", default="", help='per-word overrides, e.g. "omo=pcm,sha=pcm"')
|
| 340 |
+
ap.add_argument("--force_lang", choices=LANGS)
|
| 341 |
+
ap.add_argument("--lid", choices=["none", "glotlid"], default="none")
|
| 342 |
+
a = ap.parse_args()
|
| 343 |
+
t = Tagger(None if a.lid == "none" else a.lid, parse_map(a.lang_map))
|
| 344 |
+
print(t.tag(a.text, a.force_lang))
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
if __name__ == "__main__":
|
| 348 |
+
main()
|
code/tts_engine.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streaming TTS with Flash-DPO (Dynamic Accent, CosyVoice3 engine): the tagged reply is cut into chunks
|
| 2 |
+
(a short first chunk for latency, then sentence by sentence) and each chunk is synthesised as soon as the previous is done."""
|
| 3 |
+
import os, sys, re, time
|
| 4 |
+
import numpy as np
|
| 5 |
+
REL = os.environ.get("DA_REL", "/home/dev/fi/reltest")
|
| 6 |
+
sys.path.insert(0, REL)
|
| 7 |
+
os.environ.setdefault("CV3_DIR", "/home/dev/models/Fun-CosyVoice3-0.5B-2512")
|
| 8 |
+
os.environ.setdefault("DA_PROMPTS", REL + "/prompts")
|
| 9 |
+
TAG = re.compile(r"<(eng|ibo|yor|hau|pcm)>(.*?)</\1>", re.S)
|
| 10 |
+
VOICE = {"anchor_male": "anchor", "waxal_ibo_5_female": "waxal_ibo_5", "waxal_yor_5_female": "waxal_yor_5"}
|
| 11 |
+
|
| 12 |
+
def chunk_tagged(tagged, first_max_words=6):
|
| 13 |
+
"""-> list of tagged chunk strings. Splits at sentence ends (. ! ?), keeps tags, first chunk short (also cut at a comma/greeting)."""
|
| 14 |
+
units, cur = [], []
|
| 15 |
+
for l, t in TAG.findall(tagged):
|
| 16 |
+
parts = re.split(r"(?<=[.!?])\s+", t.strip())
|
| 17 |
+
for k, s in enumerate(parts):
|
| 18 |
+
if not s: continue
|
| 19 |
+
cur.append((l, s))
|
| 20 |
+
if re.search(r"[.!?]$", s):
|
| 21 |
+
units.append(cur); cur = []
|
| 22 |
+
if cur: units.append(cur)
|
| 23 |
+
if not units: return []
|
| 24 |
+
first = units[0]
|
| 25 |
+
n = sum(len(s.split()) for _, s in first)
|
| 26 |
+
if n > first_max_words: # fast first chunk: cut the first sentence at a comma / tag boundary / conjunction
|
| 27 |
+
if len(first) > 1 and len(first[0][1].split()) <= first_max_words:
|
| 28 |
+
units[0] = [first[0]]; units.insert(1, first[1:])
|
| 29 |
+
else:
|
| 30 |
+
l, s = first[0]
|
| 31 |
+
m = re.search(r"[,;:]\s", s)
|
| 32 |
+
if not (m and 1 <= len(s[:m.start()].split()) <= first_max_words):
|
| 33 |
+
m = None
|
| 34 |
+
ws = s.split()
|
| 35 |
+
for k in range(3, min(first_max_words, len(ws) - 3)):
|
| 36 |
+
if ws[k].lower() in ("and", "but", "so", "because", "then", "or"):
|
| 37 |
+
m = re.search(r"\s" + re.escape(ws[k]) + r"\s", s); break
|
| 38 |
+
if m:
|
| 39 |
+
cut = m.end() if s[m.start()] in ",;:" else m.start()
|
| 40 |
+
units[0] = [(l, s[:cut].strip())]; units.insert(1, [(l, s[cut:].strip())] + first[1:])
|
| 41 |
+
# hard caps: first chunk <= 10 words, later chunks <= 22 words (split at a comma if possible, else at a word boundary)
|
| 42 |
+
def split_unit(u, cap):
|
| 43 |
+
out, cur, n = [], [], 0
|
| 44 |
+
for l, s in u:
|
| 45 |
+
ws = s.split()
|
| 46 |
+
while n + len(ws) > cap:
|
| 47 |
+
k = cap - n
|
| 48 |
+
sub = " ".join(ws[:k]); c = sub.rfind(",")
|
| 49 |
+
if c > len(sub) // 3: k = len(sub[:c + 1].split())
|
| 50 |
+
if k > 0: cur.append((l, " ".join(ws[:k])))
|
| 51 |
+
out.append(cur); cur, n, ws = [], 0, ws[k:]
|
| 52 |
+
if ws: cur.append((l, " ".join(ws))); n += len(ws)
|
| 53 |
+
if cur: out.append(cur)
|
| 54 |
+
return out
|
| 55 |
+
capped = []
|
| 56 |
+
for i, u in enumerate(units):
|
| 57 |
+
capped += split_unit(u, 10 if i == 0 else 22)
|
| 58 |
+
units = [u for u in capped if u]
|
| 59 |
+
return [" ".join(f"<{l}>{s}</{l}>" for l, s in u) for u in units]
|
| 60 |
+
|
| 61 |
+
class TTS:
|
| 62 |
+
def __init__(self, voice="anchor_male", lm=None, diac=None, tail_pad=4):
|
| 63 |
+
import flash_lm
|
| 64 |
+
from frontend import FrontEnd
|
| 65 |
+
lm = lm or f"{REL}/checkpoints/flash/flash_dpo/best.pt"
|
| 66 |
+
diac = diac or f"{REL}/checkpoints/flash/diacritics_byt5"
|
| 67 |
+
self.S = flash_lm.Synth(lm)
|
| 68 |
+
self.fe = FrontEnd(diac if os.path.isdir(diac) else None)
|
| 69 |
+
self.sr = self.S.cosy.sample_rate
|
| 70 |
+
self.voice = VOICE.get(voice, voice); self.tail_pad = tail_pad
|
| 71 |
+
self.synth("<eng>Warm up.</eng>") # compile/cache voice prompt
|
| 72 |
+
|
| 73 |
+
def synth(self, tagged, seed=0, voice=None):
|
| 74 |
+
import torch
|
| 75 |
+
v = VOICE.get(voice, voice) if voice else self.voice
|
| 76 |
+
t0 = time.perf_counter()
|
| 77 |
+
# diacritic restoration only for ibo/yor spans written without any tone marks (the LLM usually writes them)
|
| 78 |
+
need = any(l in ("ibo", "yor") and not re.search(r"[\u0300-\u036f\u1ea0-\u1ef9ịụọẹṣńḿ]", __import__("unicodedata").normalize("NFD", s) + s) for l, s in TAG.findall(tagged))
|
| 79 |
+
t = self.fe(tagged, "codeswitch", "eng") if need else tagged
|
| 80 |
+
t1 = time.perf_counter()
|
| 81 |
+
toks = list(self.S.tokens(t, v, "normal", seed)[0]); torch.cuda.synchronize()
|
| 82 |
+
t2 = time.perf_counter()
|
| 83 |
+
if self.tail_pad: toks += [1] * self.tail_pad
|
| 84 |
+
w = np.asarray(self.S.wav(toks, v), dtype=np.float32)
|
| 85 |
+
t3 = time.perf_counter()
|
| 86 |
+
self.last = dict(fe=round(t1 - t0, 3), lm=round(t2 - t1, 3), flow_voc=round(t3 - t2, 3), n_tok=len(toks), audio_s=round(len(w) / self.sr, 2))
|
| 87 |
+
return w
|
| 88 |
+
|
| 89 |
+
def stream(self, tagged, seed=0, voice=None):
|
| 90 |
+
"""yields (chunk_text, wav, synth_seconds)"""
|
| 91 |
+
for i, c in enumerate(chunk_tagged(tagged)):
|
| 92 |
+
t = time.perf_counter(); w = self.synth(c, seed + 101 * i, voice)
|
| 93 |
+
yield c, w, time.perf_counter() - t
|
code/turn.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Duplex turn-taking: Silero VAD (MIT) per 32 ms frame, end-of-turn by trailing silence, barge-in while the agent
|
| 2 |
+
speaks/thinks, and light backchannel cues at mid-turn pauses. Pure frame clock (16 kHz, 512-sample frames)."""
|
| 3 |
+
import torch, numpy as np
|
| 4 |
+
FR = 512; SR = 16000; FMS = FR / SR
|
| 5 |
+
|
| 6 |
+
class TurnDetector:
|
| 7 |
+
def __init__(self, eot_ms=600, start_ms=96, barge_ms=192, on=0.5, off=0.35, preroll_ms=300,
|
| 8 |
+
bc_min_turn_s=4.5, bc_pause_ms=(288, 448), bc_gap_s=10.0, spec_ms=256):
|
| 9 |
+
from silero_vad import load_silero_vad
|
| 10 |
+
self.vad = load_silero_vad()
|
| 11 |
+
self.eot_f = int(eot_ms / 1000 / FMS); self.start_f = max(1, int(start_ms / 1000 / FMS)); self.barge_f = int(barge_ms / 1000 / FMS)
|
| 12 |
+
self.on, self.off = on, off; self.pre_f = int(preroll_ms / 1000 / FMS)
|
| 13 |
+
self.spec_f = int(spec_ms / 1000 / FMS) if spec_ms else 0
|
| 14 |
+
self.bc_min, self.bc_pause, self.bc_gap = bc_min_turn_s, (int(bc_pause_ms[0] / 1000 / FMS), int(bc_pause_ms[1] / 1000 / FMS)), bc_gap_s
|
| 15 |
+
self.reset()
|
| 16 |
+
|
| 17 |
+
def reset(self):
|
| 18 |
+
self.vad.reset_states(); self.t = 0.0; self.in_speech = False; self.on_count = 0; self.sil = 0
|
| 19 |
+
self.ring = []; self.buf = []; self.speech_frames = 0; self.onset_t = None; self.last_bc = -99; self.bc_done_this_pause = False
|
| 20 |
+
self.barged = False; self.busy_speech = 0; self._was_busy = False
|
| 21 |
+
|
| 22 |
+
def feed(self, frame, agent_busy=False):
|
| 23 |
+
"""frame: float32[512]. returns list of (event, info). events: start, barge, backchannel, eot"""
|
| 24 |
+
ev = []
|
| 25 |
+
p = float(self.vad(torch.from_numpy(np.ascontiguousarray(frame, dtype=np.float32)), SR).item())
|
| 26 |
+
self.t += FMS
|
| 27 |
+
sp = p > (self.off if self.in_speech else self.on)
|
| 28 |
+
if agent_busy and not self._was_busy:
|
| 29 |
+
self.busy_speech = 0; self.barged = False
|
| 30 |
+
self._was_busy = agent_busy
|
| 31 |
+
if agent_busy:
|
| 32 |
+
self.busy_speech = self.busy_speech + 1 if p > self.on else max(0, self.busy_speech - 1)
|
| 33 |
+
if not self.barged and self.busy_speech >= self.barge_f:
|
| 34 |
+
self.barged = True; ev.append(("barge", dict(t=self.t, onset=self.onset_t)))
|
| 35 |
+
if not self.in_speech:
|
| 36 |
+
self.ring.append(frame); self.ring = self.ring[-self.pre_f:]
|
| 37 |
+
self.on_count = self.on_count + 1 if sp else 0
|
| 38 |
+
if self.on_count >= self.start_f:
|
| 39 |
+
self.in_speech = True; self.sil = 0; self.speech_frames = self.on_count
|
| 40 |
+
self.onset_t = self.t - self.on_count * FMS; self.buf = list(self.ring); self.bc_done_this_pause = False
|
| 41 |
+
ev.append(("start", dict(t=self.onset_t, p=p)))
|
| 42 |
+
if agent_busy and self.on_count >= self.barge_f:
|
| 43 |
+
pass
|
| 44 |
+
else:
|
| 45 |
+
self.buf.append(frame)
|
| 46 |
+
if sp:
|
| 47 |
+
if self.spec_f and self.sil >= self.spec_f:
|
| 48 |
+
ev.append(("resume", dict(t=self.t))) # speculative response must be dropped
|
| 49 |
+
self.sil = 0; self.speech_frames += 1; self.bc_done_this_pause = False
|
| 50 |
+
else:
|
| 51 |
+
self.sil += 1
|
| 52 |
+
if self.spec_f and self.sil == self.spec_f:
|
| 53 |
+
keep = len(self.buf) - self.sil + int(0.2 / FMS)
|
| 54 |
+
ev.append(("spec", dict(t=self.t, speech_end=self.t - self.sil * FMS, audio=np.concatenate(self.buf[:max(1, keep)]))))
|
| 55 |
+
dur = self.speech_frames * FMS
|
| 56 |
+
if (not agent_busy and dur >= self.bc_min and self.bc_pause[0] <= self.sil <= self.bc_pause[1]
|
| 57 |
+
and not self.bc_done_this_pause and self.t - self.last_bc >= self.bc_gap):
|
| 58 |
+
self.bc_done_this_pause = True; self.last_bc = self.t; ev.append(("backchannel", dict(t=self.t, turn_s=dur)))
|
| 59 |
+
if self.sil >= self.eot_f:
|
| 60 |
+
end_t = self.t - self.sil * FMS
|
| 61 |
+
keep = len(self.buf) - self.sil + int(0.2 / FMS)
|
| 62 |
+
audio = np.concatenate(self.buf[:max(1, keep)])
|
| 63 |
+
ev.append(("eot", dict(t=self.t, speech_end=end_t, onset=self.onset_t, audio=audio, speech_s=dur)))
|
| 64 |
+
self.in_speech = False; self.on_count = 0; self.buf = []; self.ring = []
|
| 65 |
+
return ev
|
| 66 |
+
|
| 67 |
+
def partial_audio(self):
|
| 68 |
+
return np.concatenate(self.buf) if self.buf else np.zeros(0, np.float32)
|
code/validator.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reply validator: tags well-formed, language proportions vs target mix, greeting pattern kept, length, loops.
|
| 2 |
+
Scores a candidate; the agent keeps the best passing candidate, regenerates up to N rounds, then falls back."""
|
| 3 |
+
import re
|
| 4 |
+
from collections import Counter
|
| 5 |
+
import mix as M
|
| 6 |
+
|
| 7 |
+
TAG = re.compile(r"<(eng|ibo|yor|hau|pcm)>(.*?)</\1>", re.S)
|
| 8 |
+
|
| 9 |
+
def parse(out):
|
| 10 |
+
out = out.strip().splitlines()[0].strip() if out.strip() else ""
|
| 11 |
+
spans = TAG.findall(out)
|
| 12 |
+
rest = TAG.sub(" ", out)
|
| 13 |
+
return out, spans, re.sub(r"[\s.,!?;:'\"\-–—…]", "", rest)
|
| 14 |
+
|
| 15 |
+
def check(out, target, max_words=40, min_words=2, lid="glotlid", tvd_max=0.3, user=None):
|
| 16 |
+
out, spans, rest = parse(out)
|
| 17 |
+
r = dict(text=out, ok=False, why=[], tvd=1.0)
|
| 18 |
+
if not spans:
|
| 19 |
+
r["why"].append("no tags"); return r
|
| 20 |
+
if rest:
|
| 21 |
+
r["why"].append("text outside tags")
|
| 22 |
+
words = " ".join(t for _, t in spans).split()
|
| 23 |
+
if not (min_words <= len(words) <= max_words):
|
| 24 |
+
r["why"].append(f"length {len(words)}")
|
| 25 |
+
low = [w.lower().strip(".,!?") for w in words]
|
| 26 |
+
tri = Counter(tuple(low[i:i + 3]) for i in range(len(low) - 2))
|
| 27 |
+
if tri and tri.most_common(1)[0][1] > 2:
|
| 28 |
+
r["why"].append("loop")
|
| 29 |
+
if user: # parroting: the LLM copies the user's (often mis-recognised) words instead of answering
|
| 30 |
+
uw = [w.lower().strip(".,!?") for w in user.split()]
|
| 31 |
+
ug = {tuple(uw[i:i + 3]) for i in range(len(uw) - 2)}
|
| 32 |
+
rg = [tuple(low[i:i + 3]) for i in range(len(low) - 2)]
|
| 33 |
+
r["echo"] = round(sum(g in ug for g in rg) / len(rg), 2) if rg else 0.0
|
| 34 |
+
if r["echo"] > 0.5 and len(low) >= 6: r["why"].append(f"echo {r['echo']}") # short closings may legitimately mirror the user
|
| 35 |
+
# declared mix (by tag) and measured mix (re-tag the plain text with the LID tagger)
|
| 36 |
+
n = max(1, len(words))
|
| 37 |
+
decl = Counter()
|
| 38 |
+
for l, t in spans: decl[l] += len(t.split())
|
| 39 |
+
decl = {k: v / n for k, v in decl.items()}
|
| 40 |
+
plain = " ".join(t for _, t in spans)
|
| 41 |
+
meas = M.estimate(plain, lid=lid)
|
| 42 |
+
# measured labels can confuse English/Pidgin; merge eng+pcm disagreement softly by averaging declared and measured
|
| 43 |
+
mixd = {k: 0.5 * decl.get(k, 0) + 0.5 * meas["mix"].get(k, 0) for k in set(decl) | set(meas["mix"])}
|
| 44 |
+
tgt = target["mix"]
|
| 45 |
+
d = M.tvd(mixd, tgt)
|
| 46 |
+
r.update(declared={k: round(v, 2) for k, v in decl.items()}, measured=meas["mix"], mix=({k: round(v, 2) for k, v in mixd.items() if v > 0.01}), tvd=round(d, 3))
|
| 47 |
+
# tag honesty: words tagged ibo/yor/hau that the tagger thinks are English
|
| 48 |
+
lab = meas["labels"]; i = 0; mism = 0; ng = 0
|
| 49 |
+
for l, t in spans:
|
| 50 |
+
k = len(M.tagger(lid).word_langs(t)[1]) if t.strip() else 0
|
| 51 |
+
if l in ("ibo", "yor", "hau"):
|
| 52 |
+
ng += k; mism += sum(1 for x in lab[i:i + k] if x == "eng")
|
| 53 |
+
i += k
|
| 54 |
+
r["tag_mismatch"] = round(mism / ng, 2) if ng else 0.0
|
| 55 |
+
if r["tag_mismatch"] > 0.5: r["why"].append("non-English tags over English words")
|
| 56 |
+
if d > tvd_max: r["why"].append(f"mix drift tvd={d:.2f}")
|
| 57 |
+
for l, p in tgt.items(): # every substantial target language must appear
|
| 58 |
+
if p >= 0.15 and mixd.get(l, 0) < 0.05: r["why"].append(f"missing {l}")
|
| 59 |
+
for p in target.get("pattern", []): # greeting pattern preserved
|
| 60 |
+
k, _, l = p.partition(":")
|
| 61 |
+
if k == "greeting" and spans and spans[0][0] != l: r["why"].append(f"no {l} greeting")
|
| 62 |
+
r["ok"] = not r["why"]
|
| 63 |
+
r["score"] = d + 0.25 * len(r["why"]) + 0.2 * r["tag_mismatch"]
|
| 64 |
+
return r
|
| 65 |
+
|
| 66 |
+
SAFE = {"ibo": "Ndewo!", "yor": "Ẹ pẹ̀lẹ́ o!", "hau": "Sannu!", "pcm": "Abeg no vex,"}
|
| 67 |
+
|
| 68 |
+
def fallback(text_candidates, target):
|
| 69 |
+
"""all rounds failed. Echoing/drifting native-language output -> an honest short reply (a correct phrasebook opener in the user's
|
| 70 |
+
language + a clarification request); otherwise the best-scoring candidate if its tags parse; else plain text in the matrix language."""
|
| 71 |
+
best = min(text_candidates, key=lambda c: c["score"] if "score" in c else 9, default=None)
|
| 72 |
+
m = target.get("matrix", "eng")
|
| 73 |
+
if m in SAFE and (best is None or any(w.startswith(("echo", "mix drift", "missing", "no tags", "non-English")) for w in best.get("why", []))):
|
| 74 |
+
tail = "<pcm>I no too hear you well. Abeg talk am again?</pcm>" if m == "pcm" else "<eng>Sorry, I did not catch all of that. Could you say it again, or in English?</eng>"
|
| 75 |
+
return f"<{m}>{SAFE[m]}</{m}> {tail}", "safe_phrasebook"
|
| 76 |
+
if best and TAG.search(best["text"] or ""):
|
| 77 |
+
return best["text"], "best_failed"
|
| 78 |
+
m = target.get("matrix", "eng"); raw = re.sub(r"</?\w+>", "", (best or {}).get("text", "") or "Sorry, say that again?")
|
| 79 |
+
return f"<{m}>{raw.strip()}</{m}>", "plain_matrix"
|
config.json
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "dynamic-accent-duplex",
|
| 3 |
+
"version": "0.2.0-experimental",
|
| 4 |
+
"type": "cascaded full-duplex speech-to-speech voice agent (pipeline, not a single model)",
|
| 5 |
+
"languages": [
|
| 6 |
+
"en",
|
| 7 |
+
"pcm",
|
| 8 |
+
"ig",
|
| 9 |
+
"yo",
|
| 10 |
+
"ha"
|
| 11 |
+
],
|
| 12 |
+
"code_license": "apache-2.0",
|
| 13 |
+
"library_name": "pytorch",
|
| 14 |
+
"components": {
|
| 15 |
+
"vad": {
|
| 16 |
+
"model": "snakers4/silero-vad (v5, pip silero-vad)",
|
| 17 |
+
"license": "MIT",
|
| 18 |
+
"settings": {
|
| 19 |
+
"frame_ms": 32,
|
| 20 |
+
"speech_start_ms": 96,
|
| 21 |
+
"end_of_turn_silence_ms": 600,
|
| 22 |
+
"speculative_start_ms": 256,
|
| 23 |
+
"barge_in_speech_ms": 192
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
"asr": {
|
| 27 |
+
"model": "Dynamic ASR real (Modularcomputing/dynamic-asr, real/model.pt) = Meta omniASR_CTC_1B_v2 fine-tuned on licensed Nigerian speech",
|
| 28 |
+
"source": "https://huggingface.co/Modularcomputing/dynamic-asr",
|
| 29 |
+
"license": "CC-BY-SA-4.0 (weights; base Apache-2.0)",
|
| 30 |
+
"alternatives": {
|
| 31 |
+
"dynamic-asr-cs": "codeswitch-experimental/model.pt (experimental)",
|
| 32 |
+
"omni": "facebook omniASR_CTC_1B (Apache-2.0), also the automatic fallback"
|
| 33 |
+
},
|
| 34 |
+
"env": "ASR_MODEL=dynamic-asr|dynamic-asr-cs|omni",
|
| 35 |
+
"settings": {
|
| 36 |
+
"decoding": "greedy CTC, language-agnostic, Latin-script logit mask",
|
| 37 |
+
"partials_every_s": 0.64
|
| 38 |
+
}
|
| 39 |
+
},
|
| 40 |
+
"language_id": {
|
| 41 |
+
"model": "Dynamic Accent tagger.py (rules: diacritics, lexicons, Pidgin markers) + GlotLID v3 sliding windows",
|
| 42 |
+
"glotlid": "cis-lmu/glotlid",
|
| 43 |
+
"license": "Apache-2.0 (tagger code) / Apache-2.0 (GlotLID)"
|
| 44 |
+
},
|
| 45 |
+
"reply_llm": {
|
| 46 |
+
"model": "Qwen/Qwen3-30B-A3B-Instruct-2507-FP8",
|
| 47 |
+
"license": "Apache-2.0",
|
| 48 |
+
"server": "vLLM (Apache-2.0), OpenAI-compatible, served name 'llm'",
|
| 49 |
+
"settings": {
|
| 50 |
+
"candidates_per_round": 3,
|
| 51 |
+
"rounds": 2,
|
| 52 |
+
"temperature": [
|
| 53 |
+
0.7,
|
| 54 |
+
0.9
|
| 55 |
+
],
|
| 56 |
+
"max_tokens": 110
|
| 57 |
+
}
|
| 58 |
+
},
|
| 59 |
+
"validator": {
|
| 60 |
+
"checks": [
|
| 61 |
+
"tag format",
|
| 62 |
+
"mix TVD <= 0.3 vs target",
|
| 63 |
+
"every target language >= 15% present",
|
| 64 |
+
"greeting pattern kept",
|
| 65 |
+
"length 2-40 words",
|
| 66 |
+
"no loops",
|
| 67 |
+
"no echo of the user's words",
|
| 68 |
+
"non-English tags not over English words"
|
| 69 |
+
],
|
| 70 |
+
"fallback": "best candidate if tags parse; for ibo/yor/hau/pcm-matrix turns that echo or drift: a phrasebook opener in the user's language + a clarification request"
|
| 71 |
+
},
|
| 72 |
+
"tts": {
|
| 73 |
+
"model": "Dynamic Accent Flash-DPO (checkpoints/flash/flash_dpo in Modularcomputing/dynamic-accent-pro-v1.1)",
|
| 74 |
+
"base": "FunAudioLLM/Fun-CosyVoice3-0.5B-2512 (Apache-2.0)",
|
| 75 |
+
"license": "CC-BY-SA-4.0 (weights, voice prompts)",
|
| 76 |
+
"voice": "anchor_male",
|
| 77 |
+
"streaming": "chunk-level: first chunk <= 6-10 words, then sentences <= 22 words",
|
| 78 |
+
"diacritics": "Dynamic Accent ByT5 diacritic restorer (CC-BY-SA-4.0; google/byt5-small Apache-2.0 base), only for ibo/yor spans without tone marks"
|
| 79 |
+
},
|
| 80 |
+
"backchannels": {
|
| 81 |
+
"phrases": {
|
| 82 |
+
"eng": "Mm-hmm.",
|
| 83 |
+
"pcm": "Ehen.",
|
| 84 |
+
"ibo": "Ehen.",
|
| 85 |
+
"yor": "Ehen.",
|
| 86 |
+
"hau": "To."
|
| 87 |
+
},
|
| 88 |
+
"rule": "user turn >= 4.5 s, mid-turn pause 288-448 ms, >= 10 s apart"
|
| 89 |
+
}
|
| 90 |
+
},
|
| 91 |
+
"not_included_weights": "This repo ships code and config only; every model is downloaded from its own repository under its own licence.",
|
| 92 |
+
"excluded_on_licence": [
|
| 93 |
+
"African Voices / WaZoBia (TTS use banned)",
|
| 94 |
+
"NaijaVoices (non-commercial)",
|
| 95 |
+
"N-ATLaS (1,000-user cap, share-alike)",
|
| 96 |
+
"Gemma 3 (gated; not tested)"
|
| 97 |
+
]
|
| 98 |
+
}
|
launch.sh
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Start the three processes of Dynamic Accent Duplex on one GPU (tested on 1x H100 80GB).
|
| 3 |
+
# 1) vLLM OpenAI server with the reply LLM (Qwen3-30B-A3B-Instruct-2507-FP8, Apache-2.0) :8001
|
| 4 |
+
# 2) ASR worker: Dynamic ASR (Modularcomputing/dynamic-asr, fine-tuned omniASR_CTC_1B_v2; needs `pip install "omnilingual-asr>=0.2.0" huggingface_hub`) :8002
|
| 5 |
+
# ASR_MODEL=dynamic-asr (default) | dynamic-asr-cs | omni (stock omniASR_CTC_1B; also the automatic fallback)
|
| 6 |
+
# 3) duplex server (VAD + turn-taking + LID/mix + validator + Flash TTS) + web UI :7860
|
| 7 |
+
# TTS: set DA_REL to a local copy of huggingface.co/Modularcomputing/dynamic-accent-pro-v1.1 (Flash-DPO weights,
|
| 8 |
+
# CC-BY-SA-4.0) set up as its README describes (CosyVoice repo + Fun-CosyVoice3-0.5B-2512 base, CV3_DIR).
|
| 9 |
+
set -e
|
| 10 |
+
HERE=$(cd $(dirname $0); pwd)
|
| 11 |
+
: "${DA_REL:?set DA_REL to the dynamic-accent-pro-v1.1 folder}"
|
| 12 |
+
VLLM=${VLLM:-vllm}; PY_ASR=${PY_ASR:-python}; PY=${PY:-python}
|
| 13 |
+
$VLLM serve Qwen/Qwen3-30B-A3B-Instruct-2507-FP8 --served-model-name llm --port 8001 --gpu-memory-utilization 0.62 --max-model-len 8192 &
|
| 14 |
+
(cd $HERE/code && ASR_MODEL=${ASR_MODEL:-dynamic-asr} $PY_ASR -m uvicorn asr_server:app --host 127.0.0.1 --port 8002) &
|
| 15 |
+
until curl -s localhost:8001/v1/models >/dev/null && curl -s localhost:8002/health >/dev/null; do sleep 5; done
|
| 16 |
+
cd $HERE/code && DA_REL=$DA_REL $PY server.py # open http://localhost:7860 (use headphones)
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# duplex server / offline driver (same env as the Dynamic Accent Flash TTS: torch, CosyVoice deps)
|
| 2 |
+
fastapi
|
| 3 |
+
uvicorn[standard]
|
| 4 |
+
websockets
|
| 5 |
+
httpx
|
| 6 |
+
silero-vad # MIT
|
| 7 |
+
fasttext-wheel # GlotLID (cis-lmu/glotlid, Apache-2.0) for language ID
|
| 8 |
+
huggingface_hub
|
| 9 |
+
soundfile
|
| 10 |
+
librosa
|
| 11 |
+
numpy
|
| 12 |
+
# ASR worker env (separate venv is fine): omnilingual-asr>=0.2.0 (Apache-2.0), huggingface_hub, fastapi, uvicorn (Dynamic ASR weights: Modularcomputing/dynamic-asr)
|
| 13 |
+
# LLM server: vllm
|
samples/01_igbo_greetings_english.json
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "01_igbo_greetings_english",
|
| 3 |
+
"desc": "Mostly English with Igbo greetings/closing (ACE's target case). Includes a long turn (backchannel) and a barge-in. User turns are TTS-synthesized (Flash, female Igbo voice).",
|
| 4 |
+
"user_turns": [
|
| 5 |
+
{
|
| 6 |
+
"label": "TTS-synthesized user turn",
|
| 7 |
+
"voice": "waxal_ibo_5_female",
|
| 8 |
+
"text": "<ibo>Ndewo!</ibo> <eng>Good morning. Please, can you help me plan my day? I have a lot to do.</eng>"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"label": "TTS-synthesized user turn (long, mid-turn pause)",
|
| 12 |
+
"voice": "waxal_ibo_5_female",
|
| 13 |
+
"text_parts": [
|
| 14 |
+
"<eng>I need to go to the bank, and then the market,</eng>",
|
| 15 |
+
"<eng>and in the evening I also want to call my mother.</eng>"
|
| 16 |
+
],
|
| 17 |
+
"inner_pause": 0.4
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"label": "TTS-synthesized user turn (barge-in 1.5 s into the reply)",
|
| 21 |
+
"voice": "waxal_ibo_5_female",
|
| 22 |
+
"text": "<eng>Wait, sorry, the bank closes early today.</eng>",
|
| 23 |
+
"barge_at": 1.5
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"label": "TTS-synthesized user turn",
|
| 27 |
+
"voice": "waxal_ibo_5_female",
|
| 28 |
+
"text": "<eng>Okay, thank you so much.</eng> <ibo>Daalụ!</ibo>"
|
| 29 |
+
}
|
| 30 |
+
],
|
| 31 |
+
"turns": [
|
| 32 |
+
{
|
| 33 |
+
"turn": 0,
|
| 34 |
+
"user_label": "TTS-synthesized user turn",
|
| 35 |
+
"user_ref": "<ibo>Ndewo!</ibo> <eng>Good morning. Please, can you help me plan my day? I have a lot to do.</eng>",
|
| 36 |
+
"user_start": 0.7,
|
| 37 |
+
"user_onset": 0.762,
|
| 38 |
+
"backchannels": [],
|
| 39 |
+
"asr": "nde wo good morning please can you help me plan my day i have a lot to do",
|
| 40 |
+
"asr_s": 0.073,
|
| 41 |
+
"eot_wait_s": 0.576,
|
| 42 |
+
"speech_end": 5.984,
|
| 43 |
+
"target": {
|
| 44 |
+
"mix": {
|
| 45 |
+
"eng": 0.89,
|
| 46 |
+
"ibo": 0.11
|
| 47 |
+
},
|
| 48 |
+
"matrix": "eng",
|
| 49 |
+
"pattern": [
|
| 50 |
+
"greeting:ibo",
|
| 51 |
+
"inter-sentential"
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
"target_desc": "Match the user's language mix: English about 90%, Igbo about 10% of the words. The main (matrix) language is English. The user greeted in Igbo: open with a short Igbo greeting.",
|
| 55 |
+
"reply": "<ibo>Ndewo!</ibo> <eng>Of course! Let’s start with your most urgent tasks. What’s the first thing you need to do today?</eng>",
|
| 56 |
+
"reply_mix": {
|
| 57 |
+
"eng": 0.95,
|
| 58 |
+
"ibo": 0.05
|
| 59 |
+
},
|
| 60 |
+
"tvd": 0.059,
|
| 61 |
+
"how": "first_round",
|
| 62 |
+
"first_cand_ok": true,
|
| 63 |
+
"rounds": [
|
| 64 |
+
{
|
| 65 |
+
"round": 0,
|
| 66 |
+
"llm_s": 0.216,
|
| 67 |
+
"cands": [
|
| 68 |
+
{
|
| 69 |
+
"text": "<ibo>Ndewo!</ibo> <eng>Of course! Let’s start with your most urgent tasks. What’s the first thing you need to do today?</eng>",
|
| 70 |
+
"ok": true,
|
| 71 |
+
"why": [],
|
| 72 |
+
"tvd": 0.059
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"text": "<ibo>Ndewo!</ibo> <eng>Sure, I can help. Let’s list your tasks first—what’s the most urgent thing you need to do today?</eng>",
|
| 76 |
+
"ok": true,
|
| 77 |
+
"why": [],
|
| 78 |
+
"tvd": 0.059
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"text": "<ibo>Ndewo!</ibo> <eng>Of course! Let’s start with your most urgent tasks. What’s the first thing you need to do today?</eng>",
|
| 82 |
+
"ok": true,
|
| 83 |
+
"why": [],
|
| 84 |
+
"tvd": 0.059
|
| 85 |
+
}
|
| 86 |
+
]
|
| 87 |
+
}
|
| 88 |
+
],
|
| 89 |
+
"mix_s": 0.0124,
|
| 90 |
+
"llm_s": 0.216,
|
| 91 |
+
"tts_first_s": 0.781,
|
| 92 |
+
"spec_start_s": 0.256,
|
| 93 |
+
"compute_to_first_audio_s": 1.096,
|
| 94 |
+
"tts_breakdown": {
|
| 95 |
+
"fe": 0.0,
|
| 96 |
+
"lm": 1.003,
|
| 97 |
+
"flow_voc": 0.214,
|
| 98 |
+
"n_tok": 60,
|
| 99 |
+
"audio_s": 2.4
|
| 100 |
+
},
|
| 101 |
+
"tts_chunks_s": [
|
| 102 |
+
0.781,
|
| 103 |
+
0.69,
|
| 104 |
+
1.495,
|
| 105 |
+
1.217
|
| 106 |
+
],
|
| 107 |
+
"first_audio": 7.335,
|
| 108 |
+
"reply_end": 15.295,
|
| 109 |
+
"latency_s": 1.351
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"turn": 1,
|
| 113 |
+
"user_label": "TTS-synthesized user turn (long, mid-turn pause)",
|
| 114 |
+
"user_ref": "",
|
| 115 |
+
"user_start": 15.995,
|
| 116 |
+
"user_onset": 16.051,
|
| 117 |
+
"backchannels": [
|
| 118 |
+
{
|
| 119 |
+
"t": 22.304,
|
| 120 |
+
"turn_s": 5.28
|
| 121 |
+
}
|
| 122 |
+
],
|
| 123 |
+
"backchannel_at": 22.304,
|
| 124 |
+
"asr": "i need to go to the bank and then the market and in the evening i also want to call my mother",
|
| 125 |
+
"asr_s": 0.065,
|
| 126 |
+
"eot_wait_s": 0.576,
|
| 127 |
+
"speech_end": 22.016,
|
| 128 |
+
"target": {
|
| 129 |
+
"mix": {
|
| 130 |
+
"eng": 0.97,
|
| 131 |
+
"ibo": 0.03
|
| 132 |
+
},
|
| 133 |
+
"matrix": "eng",
|
| 134 |
+
"pattern": []
|
| 135 |
+
},
|
| 136 |
+
"target_desc": "Match the user's language mix: English about 95%, Igbo about 5% of the words. The main (matrix) language is English.",
|
| 137 |
+
"reply": "<eng>Okay, I got you. Start with the bank, then the market, and save calling your mother for evening. Want me to set a reminder?</eng>",
|
| 138 |
+
"reply_mix": {
|
| 139 |
+
"eng": 1.0
|
| 140 |
+
},
|
| 141 |
+
"tvd": 0.03,
|
| 142 |
+
"how": "first_round",
|
| 143 |
+
"first_cand_ok": true,
|
| 144 |
+
"rounds": [
|
| 145 |
+
{
|
| 146 |
+
"round": 0,
|
| 147 |
+
"llm_s": 0.257,
|
| 148 |
+
"cands": [
|
| 149 |
+
{
|
| 150 |
+
"text": "<eng>Okay, I’ll help you plan. Start with the bank—go early to avoid queues. Then the market. Call your mother after dinner, when you’re relaxed.</eng> <ibo>Ọ dị mma.</ibo>",
|
| 151 |
+
"ok": true,
|
| 152 |
+
"why": [],
|
| 153 |
+
"tvd": 0.081
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"text": "<eng>Okay, I got you. Start with the bank, then the market, and save calling your mother for evening. Want me to set a reminder?</eng>",
|
| 157 |
+
"ok": true,
|
| 158 |
+
"why": [],
|
| 159 |
+
"tvd": 0.03
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"text": "<eng>Okay, let’s plan that. Go to the bank first, then the market, and call your mum in the evening. Need a reminder for the call?</eng>",
|
| 163 |
+
"ok": true,
|
| 164 |
+
"why": [],
|
| 165 |
+
"tvd": 0.03
|
| 166 |
+
}
|
| 167 |
+
]
|
| 168 |
+
}
|
| 169 |
+
],
|
| 170 |
+
"mix_s": 0.0158,
|
| 171 |
+
"llm_s": 0.257,
|
| 172 |
+
"tts_first_s": 0.976,
|
| 173 |
+
"spec_start_s": 0.256,
|
| 174 |
+
"compute_to_first_audio_s": 1.333,
|
| 175 |
+
"tts_breakdown": {
|
| 176 |
+
"fe": 0.0,
|
| 177 |
+
"lm": 0.966,
|
| 178 |
+
"flow_voc": 0.217,
|
| 179 |
+
"n_tok": 58,
|
| 180 |
+
"audio_s": 2.32
|
| 181 |
+
},
|
| 182 |
+
"tts_chunks_s": [
|
| 183 |
+
0.976,
|
| 184 |
+
2.443,
|
| 185 |
+
1.183
|
| 186 |
+
],
|
| 187 |
+
"first_audio": 23.605,
|
| 188 |
+
"reply_end": 33.528,
|
| 189 |
+
"latency_s": 1.589
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"turn": 2,
|
| 193 |
+
"user_label": "TTS-synthesized user turn (barge-in 1.5 s into the reply)",
|
| 194 |
+
"user_ref": "<eng>Wait, sorry, the bank closes early today.</eng>",
|
| 195 |
+
"user_start": 25.105,
|
| 196 |
+
"user_onset": 25.174,
|
| 197 |
+
"backchannels": [],
|
| 198 |
+
"barge": {
|
| 199 |
+
"user_onset": 25.174,
|
| 200 |
+
"detected": 25.376,
|
| 201 |
+
"agent_stopped": 25.416,
|
| 202 |
+
"reaction_s": 0.242,
|
| 203 |
+
"agent_chunks_played": 1,
|
| 204 |
+
"agent_chunks_total": 3
|
| 205 |
+
},
|
| 206 |
+
"asr": "wait sorry the bank closes ale today",
|
| 207 |
+
"asr_s": 0.057,
|
| 208 |
+
"eot_wait_s": 0.576,
|
| 209 |
+
"speech_end": 27.488,
|
| 210 |
+
"target": {
|
| 211 |
+
"mix": {
|
| 212 |
+
"eng": 0.98
|
| 213 |
+
},
|
| 214 |
+
"matrix": "eng",
|
| 215 |
+
"pattern": []
|
| 216 |
+
},
|
| 217 |
+
"target_desc": "Match the user's language mix: English about 100% of the words. The main (matrix) language is English. Reply only in English.",
|
| 218 |
+
"reply": "<eng>Okay, no problem. Let’s plan for tomorrow instead. Should we adjust your market visit or call time?</eng>",
|
| 219 |
+
"reply_mix": {
|
| 220 |
+
"eng": 1.0
|
| 221 |
+
},
|
| 222 |
+
"tvd": 0.01,
|
| 223 |
+
"how": "first_round",
|
| 224 |
+
"first_cand_ok": true,
|
| 225 |
+
"rounds": [
|
| 226 |
+
{
|
| 227 |
+
"round": 0,
|
| 228 |
+
"llm_s": 0.246,
|
| 229 |
+
"cands": [
|
| 230 |
+
{
|
| 231 |
+
"text": "<eng>Okay, no problem. Let’s plan for tomorrow instead. Should we adjust your market visit or call time?</eng>",
|
| 232 |
+
"ok": true,
|
| 233 |
+
"why": [],
|
| 234 |
+
"tvd": 0.01
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"text": "<eng>Okay, no problem. Let’s adjust—go to the market first, then call your mum in the evening. Want me to set a reminder?</eng>",
|
| 238 |
+
"ok": true,
|
| 239 |
+
"why": [],
|
| 240 |
+
"tvd": 0.01
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"text": "<eng>Oh no worries—let’s adjust. We can go to the market first, then call your mum, and the bank can wait till tomorrow.</eng>",
|
| 244 |
+
"ok": true,
|
| 245 |
+
"why": [],
|
| 246 |
+
"tvd": 0.01
|
| 247 |
+
}
|
| 248 |
+
]
|
| 249 |
+
}
|
| 250 |
+
],
|
| 251 |
+
"mix_s": 0.0055,
|
| 252 |
+
"llm_s": 0.246,
|
| 253 |
+
"tts_first_s": 0.891,
|
| 254 |
+
"spec_start_s": 0.256,
|
| 255 |
+
"compute_to_first_audio_s": 1.214,
|
| 256 |
+
"tts_breakdown": {
|
| 257 |
+
"fe": 0.0,
|
| 258 |
+
"lm": 1.424,
|
| 259 |
+
"flow_voc": 0.221,
|
| 260 |
+
"n_tok": 85,
|
| 261 |
+
"audio_s": 3.4
|
| 262 |
+
},
|
| 263 |
+
"tts_chunks_s": [
|
| 264 |
+
0.891,
|
| 265 |
+
1.124,
|
| 266 |
+
1.645
|
| 267 |
+
],
|
| 268 |
+
"first_audio": 28.958,
|
| 269 |
+
"reply_end": 36.358,
|
| 270 |
+
"latency_s": 1.47
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"turn": 3,
|
| 274 |
+
"user_label": "TTS-synthesized user turn",
|
| 275 |
+
"user_ref": "<eng>Okay, thank you so much.</eng> <ibo>Daalụ!</ibo>",
|
| 276 |
+
"user_start": 37.058,
|
| 277 |
+
"user_onset": 37.119,
|
| 278 |
+
"backchannels": [],
|
| 279 |
+
"asr": "okay thank you somuch daalo",
|
| 280 |
+
"asr_s": 0.056,
|
| 281 |
+
"eot_wait_s": 0.576,
|
| 282 |
+
"speech_end": 39.488,
|
| 283 |
+
"target": {
|
| 284 |
+
"mix": {
|
| 285 |
+
"eng": 0.79,
|
| 286 |
+
"ibo": 0.2
|
| 287 |
+
},
|
| 288 |
+
"matrix": "eng",
|
| 289 |
+
"pattern": [
|
| 290 |
+
"closing:ibo",
|
| 291 |
+
"inter-sentential"
|
| 292 |
+
]
|
| 293 |
+
},
|
| 294 |
+
"target_desc": "Match the user's language mix: English about 80%, Igbo about 20% of the words. The main (matrix) language is English. The user closed in Igbo: you may close in Igbo.",
|
| 295 |
+
"reply": "<eng>My pleasure!</eng> <ibo>Daalụ!</ibo>",
|
| 296 |
+
"reply_mix": {
|
| 297 |
+
"eng": 0.67,
|
| 298 |
+
"ibo": 0.33
|
| 299 |
+
},
|
| 300 |
+
"tvd": 0.127,
|
| 301 |
+
"how": "first_round",
|
| 302 |
+
"first_cand_ok": true,
|
| 303 |
+
"rounds": [
|
| 304 |
+
{
|
| 305 |
+
"round": 0,
|
| 306 |
+
"llm_s": 0.177,
|
| 307 |
+
"cands": [
|
| 308 |
+
{
|
| 309 |
+
"text": "<eng>My pleasure!</eng> <ibo>Daalụ!</ibo>",
|
| 310 |
+
"ok": true,
|
| 311 |
+
"why": [],
|
| 312 |
+
"tvd": 0.127
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"text": "<eng>Anytime, no wahala.</eng> <ibo>Daalụ ọma!</ibo>",
|
| 316 |
+
"ok": false,
|
| 317 |
+
"why": [
|
| 318 |
+
"mix drift tvd=0.49"
|
| 319 |
+
],
|
| 320 |
+
"tvd": 0.495
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"text": "<eng>Anytime, no wahala.</eng> <ibo>Daalụ!</ibo>",
|
| 324 |
+
"ok": false,
|
| 325 |
+
"why": [
|
| 326 |
+
"mix drift tvd=0.42"
|
| 327 |
+
],
|
| 328 |
+
"tvd": 0.42
|
| 329 |
+
}
|
| 330 |
+
]
|
| 331 |
+
}
|
| 332 |
+
],
|
| 333 |
+
"mix_s": 0.0042,
|
| 334 |
+
"llm_s": 0.177,
|
| 335 |
+
"tts_first_s": 0.758,
|
| 336 |
+
"spec_start_s": 0.256,
|
| 337 |
+
"compute_to_first_audio_s": 1.002,
|
| 338 |
+
"tts_breakdown": {
|
| 339 |
+
"fe": 0.0,
|
| 340 |
+
"lm": 0.46,
|
| 341 |
+
"flow_voc": 0.203,
|
| 342 |
+
"n_tok": 30,
|
| 343 |
+
"audio_s": 1.2
|
| 344 |
+
},
|
| 345 |
+
"tts_chunks_s": [
|
| 346 |
+
0.758,
|
| 347 |
+
0.662
|
| 348 |
+
],
|
| 349 |
+
"first_audio": 40.746,
|
| 350 |
+
"reply_end": 43.346,
|
| 351 |
+
"latency_s": 1.258
|
| 352 |
+
}
|
| 353 |
+
]
|
| 354 |
+
}
|
samples/01_igbo_greetings_english.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:18f762b80bc3ab09b920201869b0a8c4070a80cb6323ad2d1be38b92ce03d9f4
|
| 3 |
+
size 704684
|
samples/03_pidgin_bargein.json
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "03_pidgin_bargein",
|
| 3 |
+
"desc": "Nigerian Pidgin throughout, with a barge-in. User turns TTS-synthesized (Flash, female voice).",
|
| 4 |
+
"user_turns": [
|
| 5 |
+
{
|
| 6 |
+
"label": "TTS-synthesized user turn",
|
| 7 |
+
"voice": "waxal_yor_5_female",
|
| 8 |
+
"text": "<pcm>How far? Abeg, wetin I fit cook tonight wey no go take long?</pcm>"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"label": "TTS-synthesized user turn (barge-in 1.2 s into the reply)",
|
| 12 |
+
"voice": "waxal_yor_5_female",
|
| 13 |
+
"text": "<pcm>No be rice o, I no get rice for house.</pcm>",
|
| 14 |
+
"barge_at": 1.2
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"label": "TTS-synthesized user turn",
|
| 18 |
+
"voice": "waxal_yor_5_female",
|
| 19 |
+
"text": "<pcm>Okay, na so. Thank you well well.</pcm>"
|
| 20 |
+
}
|
| 21 |
+
],
|
| 22 |
+
"turns": [
|
| 23 |
+
{
|
| 24 |
+
"turn": 0,
|
| 25 |
+
"user_label": "TTS-synthesized user turn",
|
| 26 |
+
"user_ref": "<pcm>How far? Abeg, wetin I fit cook tonight wey no go take long?</pcm>",
|
| 27 |
+
"user_start": 0.7,
|
| 28 |
+
"user_onset": 0.764,
|
| 29 |
+
"backchannels": [],
|
| 30 |
+
"asr": "how far i beg waiting i fik cook tonight when wou go take long",
|
| 31 |
+
"asr_s": 0.069,
|
| 32 |
+
"eot_wait_s": 0.576,
|
| 33 |
+
"speech_end": 4.768,
|
| 34 |
+
"target": {
|
| 35 |
+
"mix": {
|
| 36 |
+
"pcm": 1.0
|
| 37 |
+
},
|
| 38 |
+
"matrix": "pcm",
|
| 39 |
+
"pattern": [
|
| 40 |
+
"greeting:pcm"
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
"target_desc": "Match the user's language mix: Nigerian Pidgin about 100% of the words. The main (matrix) language is Nigerian Pidgin. The user greeted in Nigerian Pidgin: open with a short Nigerian Pidgin greeting. Reply only in Nigerian Pidgin.",
|
| 44 |
+
"reply": "<pcm>How far? Abeg, jollof rice with pepper soup no go take long, only twenty minutes. You fit start now?</pcm>",
|
| 45 |
+
"reply_mix": {
|
| 46 |
+
"pcm": 1.0
|
| 47 |
+
},
|
| 48 |
+
"tvd": 0.0,
|
| 49 |
+
"how": "first_round",
|
| 50 |
+
"first_cand_ok": true,
|
| 51 |
+
"rounds": [
|
| 52 |
+
{
|
| 53 |
+
"round": 0,
|
| 54 |
+
"llm_s": 0.23,
|
| 55 |
+
"cands": [
|
| 56 |
+
{
|
| 57 |
+
"text": "<pcm>How far? Abeg, jollof rice with pepper soup no go take long, only twenty minutes. You fit start now?</pcm>",
|
| 58 |
+
"ok": true,
|
| 59 |
+
"why": [],
|
| 60 |
+
"tvd": 0.0
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"text": "<pcm>How far! Abeg, jollof rice with beans no go take more than thirty minutes. You fit start now?</pcm>",
|
| 64 |
+
"ok": true,
|
| 65 |
+
"why": [],
|
| 66 |
+
"tvd": 0.0
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"text": "<pcm>How far! Abeg, jollof rice with chicken no go take long, less than thirty minutes. You fit start now?</pcm>",
|
| 70 |
+
"ok": true,
|
| 71 |
+
"why": [],
|
| 72 |
+
"tvd": 0.0
|
| 73 |
+
}
|
| 74 |
+
]
|
| 75 |
+
}
|
| 76 |
+
],
|
| 77 |
+
"mix_s": 0.0106,
|
| 78 |
+
"llm_s": 0.23,
|
| 79 |
+
"tts_first_s": 0.795,
|
| 80 |
+
"spec_start_s": 0.256,
|
| 81 |
+
"compute_to_first_audio_s": 1.119,
|
| 82 |
+
"tts_breakdown": {
|
| 83 |
+
"fe": 0.0,
|
| 84 |
+
"lm": 0.592,
|
| 85 |
+
"flow_voc": 0.216,
|
| 86 |
+
"n_tok": 38,
|
| 87 |
+
"audio_s": 1.52
|
| 88 |
+
},
|
| 89 |
+
"tts_chunks_s": [
|
| 90 |
+
0.795,
|
| 91 |
+
2.575,
|
| 92 |
+
0.808
|
| 93 |
+
],
|
| 94 |
+
"first_audio": 6.143,
|
| 95 |
+
"reply_end": 15.678,
|
| 96 |
+
"latency_s": 1.375
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"turn": 1,
|
| 100 |
+
"user_label": "TTS-synthesized user turn (barge-in 1.2 s into the reply)",
|
| 101 |
+
"user_ref": "<pcm>No be rice o, I no get rice for house.</pcm>",
|
| 102 |
+
"user_start": 7.343,
|
| 103 |
+
"user_onset": 7.609,
|
| 104 |
+
"backchannels": [],
|
| 105 |
+
"barge": {
|
| 106 |
+
"user_onset": 7.609,
|
| 107 |
+
"detected": 7.808,
|
| 108 |
+
"agent_stopped": 7.848,
|
| 109 |
+
"reaction_s": 0.239,
|
| 110 |
+
"agent_chunks_played": 1,
|
| 111 |
+
"agent_chunks_total": 3
|
| 112 |
+
},
|
| 113 |
+
"asr": "no be rice o i no get rice for huse",
|
| 114 |
+
"asr_s": 0.062,
|
| 115 |
+
"eot_wait_s": 0.576,
|
| 116 |
+
"speech_end": 11.2,
|
| 117 |
+
"target": {
|
| 118 |
+
"mix": {
|
| 119 |
+
"pcm": 1.0
|
| 120 |
+
},
|
| 121 |
+
"matrix": "pcm",
|
| 122 |
+
"pattern": [
|
| 123 |
+
"discourse:pcm"
|
| 124 |
+
]
|
| 125 |
+
},
|
| 126 |
+
"target_desc": "Match the user's language mix: Nigerian Pidgin about 100% of the words. The main (matrix) language is Nigerian Pidgin. Sprinkle a Nigerian Pidgin discourse word or two the way the user did. Reply only in Nigerian Pidgin.",
|
| 127 |
+
"reply": "<pcm>No wahala, I dey get rice for house. You fit cook e now, no go take long.</pcm>",
|
| 128 |
+
"reply_mix": {
|
| 129 |
+
"pcm": 1.0
|
| 130 |
+
},
|
| 131 |
+
"tvd": 0.0,
|
| 132 |
+
"how": "first_round",
|
| 133 |
+
"first_cand_ok": true,
|
| 134 |
+
"rounds": [
|
| 135 |
+
{
|
| 136 |
+
"round": 0,
|
| 137 |
+
"llm_s": 0.214,
|
| 138 |
+
"cands": [
|
| 139 |
+
{
|
| 140 |
+
"text": "<pcm>No wahala, I dey get rice for house. You fit cook e now, no go take long.</pcm>",
|
| 141 |
+
"ok": true,
|
| 142 |
+
"why": [],
|
| 143 |
+
"tvd": 0.0
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"text": "<pcm>No wahala, I dey get rice for house. You just tell me wetin you fit cook, I go help you quick.</pcm>",
|
| 147 |
+
"ok": true,
|
| 148 |
+
"why": [],
|
| 149 |
+
"tvd": 0.0
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"text": "<pcm>No wahala, I dey get rice for house. You fit cook e now, no go take long.</pcm>",
|
| 153 |
+
"ok": true,
|
| 154 |
+
"why": [],
|
| 155 |
+
"tvd": 0.0
|
| 156 |
+
}
|
| 157 |
+
]
|
| 158 |
+
}
|
| 159 |
+
],
|
| 160 |
+
"mix_s": 0.0084,
|
| 161 |
+
"llm_s": 0.214,
|
| 162 |
+
"tts_first_s": 0.784,
|
| 163 |
+
"spec_start_s": 0.256,
|
| 164 |
+
"compute_to_first_audio_s": 1.081,
|
| 165 |
+
"tts_breakdown": {
|
| 166 |
+
"fe": 0.0,
|
| 167 |
+
"lm": 1.385,
|
| 168 |
+
"flow_voc": 0.208,
|
| 169 |
+
"n_tok": 85,
|
| 170 |
+
"audio_s": 3.4
|
| 171 |
+
},
|
| 172 |
+
"tts_chunks_s": [
|
| 173 |
+
0.784,
|
| 174 |
+
1.183,
|
| 175 |
+
1.594
|
| 176 |
+
],
|
| 177 |
+
"first_audio": 12.538,
|
| 178 |
+
"reply_end": 19.738,
|
| 179 |
+
"latency_s": 1.338
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"turn": 2,
|
| 183 |
+
"user_label": "TTS-synthesized user turn",
|
| 184 |
+
"user_ref": "<pcm>Okay, na so. Thank you well well.</pcm>",
|
| 185 |
+
"user_start": 20.438,
|
| 186 |
+
"user_onset": 20.708,
|
| 187 |
+
"backchannels": [],
|
| 188 |
+
"asr": "ok na so thank you wew",
|
| 189 |
+
"asr_s": 0.049,
|
| 190 |
+
"eot_wait_s": 0.576,
|
| 191 |
+
"speech_end": 24.448,
|
| 192 |
+
"target": {
|
| 193 |
+
"mix": {
|
| 194 |
+
"pcm": 1.0
|
| 195 |
+
},
|
| 196 |
+
"matrix": "pcm",
|
| 197 |
+
"pattern": [
|
| 198 |
+
"discourse:pcm"
|
| 199 |
+
]
|
| 200 |
+
},
|
| 201 |
+
"target_desc": "Match the user's language mix: Nigerian Pidgin about 100% of the words. The main (matrix) language is Nigerian Pidgin. Sprinkle a Nigerian Pidgin discourse word or two the way the user did. Reply only in Nigerian Pidgin.",
|
| 202 |
+
"reply": "<pcm>Ok na so, thank you well well. We go see later, no get problem.</pcm>",
|
| 203 |
+
"reply_mix": {
|
| 204 |
+
"pcm": 1.0
|
| 205 |
+
},
|
| 206 |
+
"tvd": 0.0,
|
| 207 |
+
"how": "first_round",
|
| 208 |
+
"first_cand_ok": true,
|
| 209 |
+
"rounds": [
|
| 210 |
+
{
|
| 211 |
+
"round": 0,
|
| 212 |
+
"llm_s": 0.185,
|
| 213 |
+
"cands": [
|
| 214 |
+
{
|
| 215 |
+
"text": "<pcm>Ok na so, thank you well well. We go see later, no get problem.</pcm>",
|
| 216 |
+
"ok": true,
|
| 217 |
+
"why": [],
|
| 218 |
+
"tvd": 0.0
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"text": "<pcm>Ok na so, thank you well well. We go see later, no wahala.</pcm>",
|
| 222 |
+
"ok": true,
|
| 223 |
+
"why": [],
|
| 224 |
+
"tvd": 0.0
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"text": "<pcm>Ok na so, thank you well well. We go see later, no wahala.</pcm>",
|
| 228 |
+
"ok": true,
|
| 229 |
+
"why": [],
|
| 230 |
+
"tvd": 0.0
|
| 231 |
+
}
|
| 232 |
+
]
|
| 233 |
+
}
|
| 234 |
+
],
|
| 235 |
+
"mix_s": 0.0048,
|
| 236 |
+
"llm_s": 0.185,
|
| 237 |
+
"tts_first_s": 0.75,
|
| 238 |
+
"spec_start_s": 0.256,
|
| 239 |
+
"compute_to_first_audio_s": 1.001,
|
| 240 |
+
"tts_breakdown": {
|
| 241 |
+
"fe": 0.0,
|
| 242 |
+
"lm": 1.034,
|
| 243 |
+
"flow_voc": 0.219,
|
| 244 |
+
"n_tok": 64,
|
| 245 |
+
"audio_s": 2.56
|
| 246 |
+
},
|
| 247 |
+
"tts_chunks_s": [
|
| 248 |
+
0.75,
|
| 249 |
+
1.156,
|
| 250 |
+
1.253
|
| 251 |
+
],
|
| 252 |
+
"first_audio": 25.704,
|
| 253 |
+
"reply_end": 32.024,
|
| 254 |
+
"latency_s": 1.256
|
| 255 |
+
}
|
| 256 |
+
]
|
| 257 |
+
}
|
samples/03_pidgin_bargein.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2a6c93794a299b388091c0ece2178232ba01088f499628f2b34cc259edfdb94d
|
| 3 |
+
size 523436
|
samples/04_hausa_greetings_english.json
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "04_hausa_greetings_english",
|
| 3 |
+
"desc": "Hausa greeting + English, Hausa closing. User turns TTS-synthesized (Flash, female voice).",
|
| 4 |
+
"user_turns": [
|
| 5 |
+
{
|
| 6 |
+
"label": "TTS-synthesized user turn",
|
| 7 |
+
"voice": "waxal_ibo_5_female",
|
| 8 |
+
"text": "<hau>Sannu, barka da yamma.</hau> <eng>I am travelling to Kano tomorrow.</eng>"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"label": "TTS-synthesized user turn",
|
| 12 |
+
"voice": "waxal_ibo_5_female",
|
| 13 |
+
"text": "<eng>What should I pack for the weather there?</eng>"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"label": "TTS-synthesized user turn",
|
| 17 |
+
"voice": "waxal_ibo_5_female",
|
| 18 |
+
"text": "<hau>To, na gode.</hau>"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"turns": [
|
| 22 |
+
{
|
| 23 |
+
"turn": 0,
|
| 24 |
+
"user_label": "TTS-synthesized user turn",
|
| 25 |
+
"user_ref": "<hau>Sannu, barka da yamma.</hau> <eng>I am travelling to Kano tomorrow.</eng>",
|
| 26 |
+
"user_start": 0.7,
|
| 27 |
+
"user_onset": 0.769,
|
| 28 |
+
"backchannels": [],
|
| 29 |
+
"asr": "sannu baka da yamma i am travelling to cano tomorrow",
|
| 30 |
+
"asr_s": 0.058,
|
| 31 |
+
"eot_wait_s": 0.576,
|
| 32 |
+
"speech_end": 5.248,
|
| 33 |
+
"target": {
|
| 34 |
+
"mix": {
|
| 35 |
+
"eng": 0.6,
|
| 36 |
+
"hau": 0.4
|
| 37 |
+
},
|
| 38 |
+
"matrix": "eng",
|
| 39 |
+
"pattern": [
|
| 40 |
+
"greeting:hau",
|
| 41 |
+
"discourse:hau",
|
| 42 |
+
"inter-sentential"
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
"target_desc": "Match the user's language mix: English about 60%, Hausa about 40% of the words. The main (matrix) language is English. The user greeted in Hausa: open with a short Hausa greeting. Sprinkle a Hausa discourse word or two the way the user did.",
|
| 46 |
+
"reply": "<hau>Sannu!</hau> <eng>Good evening, I hear you're travelling to Kano tomorrow. Is it for business or family?</eng> <hau>Ba mu aji kama?</hau>",
|
| 47 |
+
"reply_mix": {
|
| 48 |
+
"eng": 0.75,
|
| 49 |
+
"hau": 0.25
|
| 50 |
+
},
|
| 51 |
+
"tvd": 0.15,
|
| 52 |
+
"how": "first_round",
|
| 53 |
+
"first_cand_ok": true,
|
| 54 |
+
"rounds": [
|
| 55 |
+
{
|
| 56 |
+
"round": 0,
|
| 57 |
+
"llm_s": 0.298,
|
| 58 |
+
"cands": [
|
| 59 |
+
{
|
| 60 |
+
"text": "<hau>Sannu!</hau> <eng>Good evening, I hear you're travelling to Kano tomorrow. Is it for business or family?</eng> <hau>Ba mu aji kama?</hau>",
|
| 61 |
+
"ok": true,
|
| 62 |
+
"why": [],
|
| 63 |
+
"tvd": 0.15
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"text": "<hau>Sannu!</hau> <eng>Good evening. I see you’re travelling to Kano tomorrow. Is it for business or pleasure?</eng>",
|
| 67 |
+
"ok": false,
|
| 68 |
+
"why": [
|
| 69 |
+
"mix drift tvd=0.31"
|
| 70 |
+
],
|
| 71 |
+
"tvd": 0.309
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"text": "<hau>Sannu baka da yamma!</hau> <eng>That’s good to hear. Are you sure you’ve packed everything for your trip to Kano?</eng>",
|
| 75 |
+
"ok": true,
|
| 76 |
+
"why": [],
|
| 77 |
+
"tvd": 0.19
|
| 78 |
+
}
|
| 79 |
+
]
|
| 80 |
+
}
|
| 81 |
+
],
|
| 82 |
+
"mix_s": 0.0077,
|
| 83 |
+
"llm_s": 0.298,
|
| 84 |
+
"tts_first_s": 0.668,
|
| 85 |
+
"spec_start_s": 0.256,
|
| 86 |
+
"compute_to_first_audio_s": 1.046,
|
| 87 |
+
"tts_breakdown": {
|
| 88 |
+
"fe": 0.0,
|
| 89 |
+
"lm": 0.659,
|
| 90 |
+
"flow_voc": 0.201,
|
| 91 |
+
"n_tok": 42,
|
| 92 |
+
"audio_s": 1.68
|
| 93 |
+
},
|
| 94 |
+
"tts_chunks_s": [
|
| 95 |
+
0.668,
|
| 96 |
+
1.721,
|
| 97 |
+
1.108,
|
| 98 |
+
0.86
|
| 99 |
+
],
|
| 100 |
+
"first_audio": 6.55,
|
| 101 |
+
"reply_end": 15.75,
|
| 102 |
+
"latency_s": 1.302
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"turn": 1,
|
| 106 |
+
"user_label": "TTS-synthesized user turn",
|
| 107 |
+
"user_ref": "<eng>What should I pack for the weather there?</eng>",
|
| 108 |
+
"user_start": 16.45,
|
| 109 |
+
"user_onset": 16.695,
|
| 110 |
+
"backchannels": [],
|
| 111 |
+
"asr": "what should i park for the weather there",
|
| 112 |
+
"asr_s": 0.049,
|
| 113 |
+
"eot_wait_s": 0.576,
|
| 114 |
+
"speech_end": 18.368,
|
| 115 |
+
"target": {
|
| 116 |
+
"mix": {
|
| 117 |
+
"eng": 0.88,
|
| 118 |
+
"hau": 0.12
|
| 119 |
+
},
|
| 120 |
+
"matrix": "eng",
|
| 121 |
+
"pattern": []
|
| 122 |
+
},
|
| 123 |
+
"target_desc": "Match the user's language mix: English about 90%, Hausa about 10% of the words. The main (matrix) language is English.",
|
| 124 |
+
"reply": "<eng>For Kano tomorrow, pack light clothes and a light jacket—weather will be warm during the day, cool at night. You need a hat too?</eng> <hau>Ya, kaiya da kaiyawa</hau>",
|
| 125 |
+
"reply_mix": {
|
| 126 |
+
"eng": 0.86,
|
| 127 |
+
"hau": 0.14
|
| 128 |
+
},
|
| 129 |
+
"tvd": 0.021,
|
| 130 |
+
"how": "first_round",
|
| 131 |
+
"first_cand_ok": true,
|
| 132 |
+
"rounds": [
|
| 133 |
+
{
|
| 134 |
+
"round": 0,
|
| 135 |
+
"llm_s": 0.335,
|
| 136 |
+
"cands": [
|
| 137 |
+
{
|
| 138 |
+
"text": "<eng>For Kano tomorrow, pack light clothes and a light jacket—weather will be hot during the day, cooler in the evening. You need a hat too?</eng> <hau>Wààtùn kààlà</hau>",
|
| 139 |
+
"ok": true,
|
| 140 |
+
"why": [],
|
| 141 |
+
"tvd": 0.083
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"text": "<eng>For the weather in Kano, pack light clothes and a hat — it’s usually hot and sunny. But bring a light jacket for evenings. You need a raincoat?</eng> <hau>Na gida?</hau>",
|
| 145 |
+
"ok": true,
|
| 146 |
+
"why": [],
|
| 147 |
+
"tvd": 0.052
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"text": "<eng>For Kano tomorrow, pack light clothes and a light jacket—weather will be warm during the day, cool at night. You need a hat too?</eng> <hau>Ya, kaiya da kaiyawa</hau>",
|
| 151 |
+
"ok": true,
|
| 152 |
+
"why": [],
|
| 153 |
+
"tvd": 0.021
|
| 154 |
+
}
|
| 155 |
+
]
|
| 156 |
+
}
|
| 157 |
+
],
|
| 158 |
+
"mix_s": 0.0062,
|
| 159 |
+
"llm_s": 0.335,
|
| 160 |
+
"tts_first_s": 0.844,
|
| 161 |
+
"spec_start_s": 0.256,
|
| 162 |
+
"compute_to_first_audio_s": 1.253,
|
| 163 |
+
"tts_breakdown": {
|
| 164 |
+
"fe": 0.0,
|
| 165 |
+
"lm": 1.232,
|
| 166 |
+
"flow_voc": 0.203,
|
| 167 |
+
"n_tok": 76,
|
| 168 |
+
"audio_s": 3.04
|
| 169 |
+
},
|
| 170 |
+
"tts_chunks_s": [
|
| 171 |
+
0.844,
|
| 172 |
+
2.871,
|
| 173 |
+
0.917,
|
| 174 |
+
1.435
|
| 175 |
+
],
|
| 176 |
+
"first_audio": 19.876,
|
| 177 |
+
"reply_end": 33.707,
|
| 178 |
+
"latency_s": 1.508
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
"turn": 2,
|
| 182 |
+
"user_label": "TTS-synthesized user turn",
|
| 183 |
+
"user_ref": "<hau>To, na gode.</hau>",
|
| 184 |
+
"user_start": 34.407,
|
| 185 |
+
"user_onset": 34.475,
|
| 186 |
+
"backchannels": [],
|
| 187 |
+
"asr": "tu na godi",
|
| 188 |
+
"asr_s": 0.049,
|
| 189 |
+
"eot_wait_s": 0.576,
|
| 190 |
+
"speech_end": 35.616,
|
| 191 |
+
"target": {
|
| 192 |
+
"mix": {
|
| 193 |
+
"hau": 0.56,
|
| 194 |
+
"eng": 0.24,
|
| 195 |
+
"ibo": 0.2
|
| 196 |
+
},
|
| 197 |
+
"matrix": "hau",
|
| 198 |
+
"pattern": [
|
| 199 |
+
"closing:hau",
|
| 200 |
+
"inter-sentential"
|
| 201 |
+
]
|
| 202 |
+
},
|
| 203 |
+
"target_desc": "Match the user's language mix: Hausa about 55%, English about 25%, Igbo about 20% of the words. The main (matrix) language is Hausa. The user closed in Hausa: you may close in Hausa.",
|
| 204 |
+
"reply": "<hau>Sannu!</hau> <eng>Sorry, I did not catch all of that. Could you say it again, or in English?</eng>",
|
| 205 |
+
"reply_mix": {
|
| 206 |
+
"eng": 0.91,
|
| 207 |
+
"hau": 0.09
|
| 208 |
+
},
|
| 209 |
+
"tvd": 0.671,
|
| 210 |
+
"how": "safe_phrasebook",
|
| 211 |
+
"first_cand_ok": false,
|
| 212 |
+
"rounds": [
|
| 213 |
+
{
|
| 214 |
+
"round": 0,
|
| 215 |
+
"llm_s": 0.264,
|
| 216 |
+
"cands": [
|
| 217 |
+
{
|
| 218 |
+
"text": "<hau>Tu na godi!</hau> <eng>Thank you for the info. Is there anything else I can help with?</eng> <ibo>Daalụ!</ibo>",
|
| 219 |
+
"ok": false,
|
| 220 |
+
"why": [
|
| 221 |
+
"mix drift tvd=0.52"
|
| 222 |
+
],
|
| 223 |
+
"tvd": 0.522
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"text": "<hau>Tu na godi!</hau> <eng>Thank you, I’m glad I could help.</eng> <ibo>Daalụ!</ibo>",
|
| 227 |
+
"ok": false,
|
| 228 |
+
"why": [
|
| 229 |
+
"mix drift tvd=0.42"
|
| 230 |
+
],
|
| 231 |
+
"tvd": 0.424
|
| 232 |
+
},
|
| 233 |
+
{
|
| 234 |
+
"text": "<hau>Tu na Godi!</hau> <eng>Thank you, I’ll keep that in mind.</eng> <ibo>Daalụ ọma!</ibo>",
|
| 235 |
+
"ok": false,
|
| 236 |
+
"why": [
|
| 237 |
+
"mix drift tvd=0.44"
|
| 238 |
+
],
|
| 239 |
+
"tvd": 0.435
|
| 240 |
+
}
|
| 241 |
+
]
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"round": 1,
|
| 245 |
+
"llm_s": 0.273,
|
| 246 |
+
"cands": [
|
| 247 |
+
{
|
| 248 |
+
"text": "<hau>Tu na Godi!</hau> <eng>Thank you for the help. I’ll make sure to pack for the weather.</eng> <ibo>Dalụ n’ụzọ ịmụ ọrụ.</ibo>",
|
| 249 |
+
"ok": false,
|
| 250 |
+
"why": [
|
| 251 |
+
"mix drift tvd=0.49"
|
| 252 |
+
],
|
| 253 |
+
"tvd": 0.485
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"text": "<hau>Tu na godi!</hau> <eng>Thank you for the info. Is the weather there usually dry this time of year?</eng> <ibo>Ọ dị mma, nke a banyere ike</ibo>",
|
| 257 |
+
"ok": false,
|
| 258 |
+
"why": [
|
| 259 |
+
"mix drift tvd=0.50"
|
| 260 |
+
],
|
| 261 |
+
"tvd": 0.5
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"text": "<hau>Tu na godi!</hau> <eng>Thank you for the info. I’ll keep you posted on the weather.</eng> <ibo>Dalụ na ọma</ibo>",
|
| 265 |
+
"ok": false,
|
| 266 |
+
"why": [
|
| 267 |
+
"mix drift tvd=0.48"
|
| 268 |
+
],
|
| 269 |
+
"tvd": 0.477
|
| 270 |
+
}
|
| 271 |
+
]
|
| 272 |
+
}
|
| 273 |
+
],
|
| 274 |
+
"mix_s": 0.0031,
|
| 275 |
+
"llm_s": 0.537,
|
| 276 |
+
"tts_first_s": 0.629,
|
| 277 |
+
"spec_start_s": 0.256,
|
| 278 |
+
"compute_to_first_audio_s": 1.249,
|
| 279 |
+
"tts_breakdown": {
|
| 280 |
+
"fe": 0.0,
|
| 281 |
+
"lm": 1.4,
|
| 282 |
+
"flow_voc": 0.224,
|
| 283 |
+
"n_tok": 86,
|
| 284 |
+
"audio_s": 3.44
|
| 285 |
+
},
|
| 286 |
+
"tts_chunks_s": [
|
| 287 |
+
0.629,
|
| 288 |
+
1.235,
|
| 289 |
+
1.625
|
| 290 |
+
],
|
| 291 |
+
"first_audio": 37.121,
|
| 292 |
+
"reply_end": 44.356,
|
| 293 |
+
"latency_s": 1.505
|
| 294 |
+
}
|
| 295 |
+
]
|
| 296 |
+
}
|
samples/04_hausa_greetings_english.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c26529b1c1611530b760d00731d371613ec639815d8416a8ec0ef471d12bd622
|
| 3 |
+
size 720812
|
samples/05_igbo_heavy.json
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "05_igbo_heavy",
|
| 3 |
+
"desc": "Igbo-heavier user (shows the weak spot: LLM Igbo beyond phrases). User turns TTS-synthesized (Flash, female Igbo voice).",
|
| 4 |
+
"user_turns": [
|
| 5 |
+
{
|
| 6 |
+
"label": "TTS-synthesized user turn",
|
| 7 |
+
"voice": "waxal_ibo_5_female",
|
| 8 |
+
"text": "<ibo>Kedu? Biko, nyere m aka.</ibo> <eng>I need a taxi to the airport.</eng>"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"label": "TTS-synthesized user turn",
|
| 12 |
+
"voice": "waxal_ibo_5_female",
|
| 13 |
+
"text": "<ibo>Ee, ọ dị mma.</ibo> <eng>I will leave at six.</eng>"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"label": "TTS-synthesized user turn",
|
| 17 |
+
"voice": "waxal_ibo_5_female",
|
| 18 |
+
"text": "<ibo>Daalụ, ka ọ dị.</ibo>"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"turns": [
|
| 22 |
+
{
|
| 23 |
+
"turn": 0,
|
| 24 |
+
"user_label": "TTS-synthesized user turn",
|
| 25 |
+
"user_ref": "<ibo>Kedu? Biko, nyere m aka.</ibo> <eng>I need a taxi to the airport.</eng>",
|
| 26 |
+
"user_start": 0.7,
|
| 27 |
+
"user_onset": 0.753,
|
| 28 |
+
"backchannels": [],
|
| 29 |
+
"asr": "kedu biko nere maaka i need a taxi to the airport",
|
| 30 |
+
"asr_s": 0.065,
|
| 31 |
+
"eot_wait_s": 0.576,
|
| 32 |
+
"speech_end": 5.632,
|
| 33 |
+
"target": {
|
| 34 |
+
"mix": {
|
| 35 |
+
"eng": 0.82,
|
| 36 |
+
"ibo": 0.18
|
| 37 |
+
},
|
| 38 |
+
"matrix": "eng",
|
| 39 |
+
"pattern": [
|
| 40 |
+
"greeting:ibo",
|
| 41 |
+
"discourse:ibo",
|
| 42 |
+
"inter-sentential"
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
"target_desc": "Match the user's language mix: English about 80%, Igbo about 20% of the words. The main (matrix) language is English. The user greeted in Igbo: open with a short Igbo greeting. Sprinkle a Igbo discourse word or two the way the user did.",
|
| 46 |
+
"reply": "<ibo>Kedu?</ibo> <eng>Okay, I can help you book a taxi to the airport. How soon do you need it?</eng> <ibo>Biko</ibo>",
|
| 47 |
+
"reply_mix": {
|
| 48 |
+
"eng": 0.89,
|
| 49 |
+
"ibo": 0.11
|
| 50 |
+
},
|
| 51 |
+
"tvd": 0.072,
|
| 52 |
+
"how": "first_round",
|
| 53 |
+
"first_cand_ok": true,
|
| 54 |
+
"rounds": [
|
| 55 |
+
{
|
| 56 |
+
"round": 0,
|
| 57 |
+
"llm_s": 0.268,
|
| 58 |
+
"cands": [
|
| 59 |
+
{
|
| 60 |
+
"text": "<ibo>Kedu?</ibo> <eng>Okay, I can help you book a taxi to the airport. How soon do you need it?</eng> <ibo>Biko</ibo>",
|
| 61 |
+
"ok": true,
|
| 62 |
+
"why": [],
|
| 63 |
+
"tvd": 0.072
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"text": "<ibo>Kedu?</ibo> <eng>Okay, I can help you book a taxi to the airport. How soon do you need it?</eng> <ibo>Biko,</ibo>",
|
| 67 |
+
"ok": true,
|
| 68 |
+
"why": [],
|
| 69 |
+
"tvd": 0.072
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"text": "<ibo>Kedu?</ibo> <eng>Okay, I can help you book a taxi to the airport. How far you want the pickup time?</eng> <ibo>Biko</ibo>",
|
| 73 |
+
"ok": true,
|
| 74 |
+
"why": [],
|
| 75 |
+
"tvd": 0.175
|
| 76 |
+
}
|
| 77 |
+
]
|
| 78 |
+
}
|
| 79 |
+
],
|
| 80 |
+
"mix_s": 0.0083,
|
| 81 |
+
"llm_s": 0.268,
|
| 82 |
+
"tts_first_s": 0.664,
|
| 83 |
+
"spec_start_s": 0.256,
|
| 84 |
+
"compute_to_first_audio_s": 1.02,
|
| 85 |
+
"tts_breakdown": {
|
| 86 |
+
"fe": 0.035,
|
| 87 |
+
"lm": 0.088,
|
| 88 |
+
"flow_voc": 0.198,
|
| 89 |
+
"n_tok": 8,
|
| 90 |
+
"audio_s": 0.32
|
| 91 |
+
},
|
| 92 |
+
"tts_chunks_s": [
|
| 93 |
+
0.664,
|
| 94 |
+
1.748,
|
| 95 |
+
1.012,
|
| 96 |
+
0.321
|
| 97 |
+
],
|
| 98 |
+
"first_audio": 6.908,
|
| 99 |
+
"reply_end": 14.696,
|
| 100 |
+
"latency_s": 1.276
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"turn": 1,
|
| 104 |
+
"user_label": "TTS-synthesized user turn",
|
| 105 |
+
"user_ref": "<ibo>Ee, ọ dị mma.</ibo> <eng>I will leave at six.</eng>",
|
| 106 |
+
"user_start": 15.396,
|
| 107 |
+
"user_onset": 15.453,
|
| 108 |
+
"backchannels": [],
|
| 109 |
+
"asr": "ee odemma iwelivet six",
|
| 110 |
+
"asr_s": 0.056,
|
| 111 |
+
"eot_wait_s": 0.576,
|
| 112 |
+
"speech_end": 18.048,
|
| 113 |
+
"target": {
|
| 114 |
+
"mix": {
|
| 115 |
+
"eng": 0.95,
|
| 116 |
+
"ibo": 0.05
|
| 117 |
+
},
|
| 118 |
+
"matrix": "eng",
|
| 119 |
+
"pattern": []
|
| 120 |
+
},
|
| 121 |
+
"target_desc": "Match the user's language mix: English about 95%, Igbo about 5% of the words. The main (matrix) language is English.",
|
| 122 |
+
"reply": "<eng>Yes, six o’clock is fine.</eng> <ibo>Ọ dị mma</ibo> <eng>Shall I confirm the booking now?</eng>",
|
| 123 |
+
"reply_mix": {
|
| 124 |
+
"eng": 0.79,
|
| 125 |
+
"ibo": 0.21
|
| 126 |
+
},
|
| 127 |
+
"tvd": 0.162,
|
| 128 |
+
"how": "first_round",
|
| 129 |
+
"first_cand_ok": false,
|
| 130 |
+
"rounds": [
|
| 131 |
+
{
|
| 132 |
+
"round": 0,
|
| 133 |
+
"llm_s": 0.248,
|
| 134 |
+
"cands": [
|
| 135 |
+
{
|
| 136 |
+
"text": "<eng>Yes, I’ll get a taxi for six. Is it okay if I confirm the pickup time with you?</eng> <ibo>Ee</ibo>",
|
| 137 |
+
"ok": false,
|
| 138 |
+
"why": [
|
| 139 |
+
"non-English tags over English words"
|
| 140 |
+
],
|
| 141 |
+
"tvd": 0.024
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"text": "<eng>Yes, six o’clock is fine. I’ll book the taxi for six.</eng> <ibo>Odemma</ibo>",
|
| 145 |
+
"ok": false,
|
| 146 |
+
"why": [
|
| 147 |
+
"non-English tags over English words"
|
| 148 |
+
],
|
| 149 |
+
"tvd": 0.008
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"text": "<eng>Yes, six o’clock is fine.</eng> <ibo>Ọ dị mma</ibo> <eng>Shall I confirm the booking now?</eng>",
|
| 153 |
+
"ok": true,
|
| 154 |
+
"why": [],
|
| 155 |
+
"tvd": 0.162
|
| 156 |
+
}
|
| 157 |
+
]
|
| 158 |
+
}
|
| 159 |
+
],
|
| 160 |
+
"mix_s": 0.0033,
|
| 161 |
+
"llm_s": 0.248,
|
| 162 |
+
"tts_first_s": 1.168,
|
| 163 |
+
"spec_start_s": 0.256,
|
| 164 |
+
"compute_to_first_audio_s": 1.488,
|
| 165 |
+
"tts_breakdown": {
|
| 166 |
+
"fe": 0.0,
|
| 167 |
+
"lm": 1.265,
|
| 168 |
+
"flow_voc": 0.206,
|
| 169 |
+
"n_tok": 78,
|
| 170 |
+
"audio_s": 3.12
|
| 171 |
+
},
|
| 172 |
+
"tts_chunks_s": [
|
| 173 |
+
1.168,
|
| 174 |
+
1.47
|
| 175 |
+
],
|
| 176 |
+
"first_audio": 19.792,
|
| 177 |
+
"reply_end": 25.312,
|
| 178 |
+
"latency_s": 1.744
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
"turn": 2,
|
| 182 |
+
"user_label": "TTS-synthesized user turn",
|
| 183 |
+
"user_ref": "<ibo>Daalụ, ka ọ d���.</ibo>",
|
| 184 |
+
"user_start": 26.012,
|
| 185 |
+
"user_onset": 26.077,
|
| 186 |
+
"backchannels": [],
|
| 187 |
+
"asr": "dalo kode",
|
| 188 |
+
"asr_s": 0.043,
|
| 189 |
+
"eot_wait_s": 0.576,
|
| 190 |
+
"speech_end": 27.488,
|
| 191 |
+
"target": {
|
| 192 |
+
"mix": {
|
| 193 |
+
"ibo": 0.5,
|
| 194 |
+
"eng": 0.47
|
| 195 |
+
},
|
| 196 |
+
"matrix": "ibo",
|
| 197 |
+
"pattern": [
|
| 198 |
+
"closing:ibo",
|
| 199 |
+
"inter-sentential"
|
| 200 |
+
]
|
| 201 |
+
},
|
| 202 |
+
"target_desc": "Match the user's language mix: Igbo about 50%, English about 45% of the words. The main (matrix) language is Igbo. The user closed in Igbo: you may close in Igbo.",
|
| 203 |
+
"reply": "<ibo>Daalụ, ọ dị mma.</ibo> <eng>Thank you, I’ve confirmed your taxi for six.</eng> <ibo>Ka ọ dị.</ibo>",
|
| 204 |
+
"reply_mix": {
|
| 205 |
+
"eng": 0.53,
|
| 206 |
+
"ibo": 0.47
|
| 207 |
+
},
|
| 208 |
+
"tvd": 0.047,
|
| 209 |
+
"how": "first_round",
|
| 210 |
+
"first_cand_ok": true,
|
| 211 |
+
"rounds": [
|
| 212 |
+
{
|
| 213 |
+
"round": 0,
|
| 214 |
+
"llm_s": 0.277,
|
| 215 |
+
"cands": [
|
| 216 |
+
{
|
| 217 |
+
"text": "<ibo>Daalụ, biko.</ibo> <eng>Thank you, I’ll confirm your taxi for six o’clock.</eng> <ibo>Ka ọ dị.</ibo>",
|
| 218 |
+
"ok": true,
|
| 219 |
+
"why": [],
|
| 220 |
+
"tvd": 0.156
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"text": "<ibo>Daalụ, biko.</ibo> <eng>Thank you, I’ll confirm your taxi for six.</eng> <ibo>Ka ọ dị.</ibo>",
|
| 224 |
+
"ok": true,
|
| 225 |
+
"why": [],
|
| 226 |
+
"tvd": 0.133
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"text": "<ibo>Daalụ, ọ dị mma.</ibo> <eng>Thank you, I’ve confirmed your taxi for six.</eng> <ibo>Ka ọ dị.</ibo>",
|
| 230 |
+
"ok": true,
|
| 231 |
+
"why": [],
|
| 232 |
+
"tvd": 0.047
|
| 233 |
+
}
|
| 234 |
+
]
|
| 235 |
+
}
|
| 236 |
+
],
|
| 237 |
+
"mix_s": 0.002,
|
| 238 |
+
"llm_s": 0.277,
|
| 239 |
+
"tts_first_s": 1.131,
|
| 240 |
+
"spec_start_s": 0.256,
|
| 241 |
+
"compute_to_first_audio_s": 1.468,
|
| 242 |
+
"tts_breakdown": {
|
| 243 |
+
"fe": 0.0,
|
| 244 |
+
"lm": 0.511,
|
| 245 |
+
"flow_voc": 0.22,
|
| 246 |
+
"n_tok": 33,
|
| 247 |
+
"audio_s": 1.32
|
| 248 |
+
},
|
| 249 |
+
"tts_chunks_s": [
|
| 250 |
+
1.131,
|
| 251 |
+
1.498,
|
| 252 |
+
0.731
|
| 253 |
+
],
|
| 254 |
+
"first_audio": 29.212,
|
| 255 |
+
"reply_end": 35.972,
|
| 256 |
+
"latency_s": 1.724
|
| 257 |
+
}
|
| 258 |
+
]
|
| 259 |
+
}
|
samples/05_igbo_heavy.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:18334bf0803cd8a34e9c4ed001a026e09e27e44badb2b01adeab91a8a98172fd
|
| 3 |
+
size 586412
|