├── .github
└── workflows
│ └── android.yml
├── .gitignore
├── .idea
├── .gitignore
├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── compiler.xml
├── deploymentTargetSelector.xml
├── dictionaries
│ └── tomyang.xml
├── gradle.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── kotlinc.xml
├── migrations.xml
├── misc.xml
├── runConfigurations.xml
└── vcs.xml
├── .kotlin
└── errors
│ └── errors-1734592217997.log
├── LICENSE
├── README.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── istomyang
│ │ └── edgetss
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── ic_launcher-playstore.png
│ ├── java
│ │ └── com
│ │ │ └── istomyang
│ │ │ └── edgetss
│ │ │ ├── data
│ │ │ ├── LogRepository.kt
│ │ │ └── SpeakerRepository.kt
│ │ │ ├── service
│ │ │ └── EdgeTTSService.kt
│ │ │ ├── ui
│ │ │ ├── MainActivity.kt
│ │ │ ├── main
│ │ │ │ ├── LogView.kt
│ │ │ │ ├── LogViewModel.kt
│ │ │ │ ├── MainView.kt
│ │ │ │ ├── SpeakerView.kt
│ │ │ │ ├── SpeakerViewModel.kt
│ │ │ │ └── component
│ │ │ │ │ ├── Button.kt
│ │ │ │ │ └── Picker.kt
│ │ │ └── theme
│ │ │ │ ├── Color.kt
│ │ │ │ ├── Theme.kt
│ │ │ │ └── Type.kt
│ │ │ └── utils
│ │ │ ├── Codec.kt
│ │ │ ├── CodecTest.kt
│ │ │ └── Player.kt
│ └── res
│ │ ├── drawable
│ │ ├── ic_launcher_foreground.xml
│ │ └── icon_launch.xml
│ │ ├── mipmap-anydpi-v26
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── values
│ │ ├── colors.xml
│ │ ├── ic_launcher_background.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ └── xml
│ │ ├── backup_rules.xml
│ │ └── data_extraction_rules.xml
│ └── test
│ └── java
│ └── com
│ └── istomyang
│ └── edgetss
│ ├── BufferUnitTest.kt
│ ├── ExampleUnitTest.kt
│ └── FlowUnitTest.kt
├── build.gradle.kts
├── docs
└── images
│ ├── Screenshot_2024-12-25-14-59-03-429_com.istomyang.edgetss.release.jpg
│ └── Screenshot_2024-12-25-14-59-36-340_com.istomyang.edgetss.release.jpg
├── engine
├── .gitignore
├── build.gradle.kts
└── src
│ ├── main
│ └── java
│ │ └── com
│ │ └── istomyang
│ │ └── tts_engine
│ │ ├── DRM.kt
│ │ ├── SpeakerManager.kt
│ │ └── TTS.kt
│ └── test
│ └── java
│ └── com
│ └── istomyang
│ └── tts_engine
│ ├── ChannelUnitTest.kt
│ ├── ExampleUnitTest.kt
│ └── SomeUnitTest.kt
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle.kts
/.github/workflows/android.yml:
--------------------------------------------------------------------------------
1 | name: Android Build and Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: write
13 |
14 | steps:
15 | - uses: actions/checkout@v4
16 | - uses: actions/setup-java@v4
17 | with:
18 | distribution: 'temurin'
19 | java-version: '17'
20 | - uses: gradle/actions/setup-gradle@v3
21 |
22 | - name: Cache Gradle dependencies
23 | uses: actions/cache@v3
24 | with:
25 | path: ~/.gradle/caches
26 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
27 | restore-keys: |
28 | ${{ runner.os }}-gradle-
29 |
30 | - name: Build APK
31 | run: |
32 | ./gradlew assembleRelease \
33 | -Psigning.keyAlias=${{ secrets.ALIAS }} \
34 | -Psigning.keyPassword=${{ secrets.KEY_PASSWORD }} \
35 | -Psigning.storeFile=$HOME/keystore.jks \
36 | -Psigning.storePassword=${{ secrets.KEYSTORE_PASSWORD }}
37 |
38 | - name: Upload APKs to GitHub Release
39 | uses: ncipollo/release-action@v1
40 | with:
41 | allowUpdates: true
42 | artifacts: app/build/outputs/apk/release/app-release.apk
43 | token: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | xmlns:android
24 |
25 | ^$
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | xmlns:.*
35 |
36 | ^$
37 |
38 |
39 | BY_NAME
40 |
41 |
42 |
43 |
44 |
45 |
46 | .*:id
47 |
48 | http://schemas.android.com/apk/res/android
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | .*:name
58 |
59 | http://schemas.android.com/apk/res/android
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | name
69 |
70 | ^$
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | style
80 |
81 | ^$
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | .*
91 |
92 | ^$
93 |
94 |
95 | BY_NAME
96 |
97 |
98 |
99 |
100 |
101 |
102 | .*
103 |
104 | http://schemas.android.com/apk/res/android
105 |
106 |
107 | ANDROID_ATTRIBUTE_ORDER
108 |
109 |
110 |
111 |
112 |
113 |
114 | .*
115 |
116 | .*
117 |
118 |
119 | BY_NAME
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/deploymentTargetSelector.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/dictionaries/tomyang.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.kotlin/errors/errors-1734592217997.log:
--------------------------------------------------------------------------------
1 | kotlin version: 2.0.21
2 | error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
3 | 1. Kotlin compile daemon is ready
4 |
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 2, June 1991
3 |
4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
6 | Everyone is permitted to copy and distribute verbatim copies
7 | of this license document, but changing it is not allowed.
8 |
9 | Preamble
10 |
11 | The licenses for most software are designed to take away your
12 | freedom to share and change it. By contrast, the GNU General Public
13 | License is intended to guarantee your freedom to share and change free
14 | software--to make sure the software is free for all its users. This
15 | General Public License applies to most of the Free Software
16 | Foundation's software and to any other program whose authors commit to
17 | using it. (Some other Free Software Foundation software is covered by
18 | the GNU Lesser General Public License instead.) You can apply it to
19 | your programs, too.
20 |
21 | When we speak of free software, we are referring to freedom, not
22 | price. Our General Public Licenses are designed to make sure that you
23 | have the freedom to distribute copies of free software (and charge for
24 | this service if you wish), that you receive source code or can get it
25 | if you want it, that you can change the software or use pieces of it
26 | in new free programs; and that you know you can do these things.
27 |
28 | To protect your rights, we need to make restrictions that forbid
29 | anyone to deny you these rights or to ask you to surrender the rights.
30 | These restrictions translate to certain responsibilities for you if you
31 | distribute copies of the software, or if you modify it.
32 |
33 | For example, if you distribute copies of such a program, whether
34 | gratis or for a fee, you must give the recipients all the rights that
35 | you have. You must make sure that they, too, receive or can get the
36 | source code. And you must show them these terms so they know their
37 | rights.
38 |
39 | We protect your rights with two steps: (1) copyright the software, and
40 | (2) offer you this license which gives you legal permission to copy,
41 | distribute and/or modify the software.
42 |
43 | Also, for each author's protection and ours, we want to make certain
44 | that everyone understands that there is no warranty for this free
45 | software. If the software is modified by someone else and passed on, we
46 | want its recipients to know that what they have is not the original, so
47 | that any problems introduced by others will not reflect on the original
48 | authors' reputations.
49 |
50 | Finally, any free program is threatened constantly by software
51 | patents. We wish to avoid the danger that redistributors of a free
52 | program will individually obtain patent licenses, in effect making the
53 | program proprietary. To prevent this, we have made it clear that any
54 | patent must be licensed for everyone's free use or not licensed at all.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | GNU GENERAL PUBLIC LICENSE
60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
61 |
62 | 0. This License applies to any program or other work which contains
63 | a notice placed by the copyright holder saying it may be distributed
64 | under the terms of this General Public License. The "Program", below,
65 | refers to any such program or work, and a "work based on the Program"
66 | means either the Program or any derivative work under copyright law:
67 | that is to say, a work containing the Program or a portion of it,
68 | either verbatim or with modifications and/or translated into another
69 | language. (Hereinafter, translation is included without limitation in
70 | the term "modification".) Each licensee is addressed as "you".
71 |
72 | Activities other than copying, distribution and modification are not
73 | covered by this License; they are outside its scope. The act of
74 | running the Program is not restricted, and the output from the Program
75 | is covered only if its contents constitute a work based on the
76 | Program (independent of having been made by running the Program).
77 | Whether that is true depends on what the Program does.
78 |
79 | 1. You may copy and distribute verbatim copies of the Program's
80 | source code as you receive it, in any medium, provided that you
81 | conspicuously and appropriately publish on each copy an appropriate
82 | copyright notice and disclaimer of warranty; keep intact all the
83 | notices that refer to this License and to the absence of any warranty;
84 | and give any other recipients of the Program a copy of this License
85 | along with the Program.
86 |
87 | You may charge a fee for the physical act of transferring a copy, and
88 | you may at your option offer warranty protection in exchange for a fee.
89 |
90 | 2. You may modify your copy or copies of the Program or any portion
91 | of it, thus forming a work based on the Program, and copy and
92 | distribute such modifications or work under the terms of Section 1
93 | above, provided that you also meet all of these conditions:
94 |
95 | a) You must cause the modified files to carry prominent notices
96 | stating that you changed the files and the date of any change.
97 |
98 | b) You must cause any work that you distribute or publish, that in
99 | whole or in part contains or is derived from the Program or any
100 | part thereof, to be licensed as a whole at no charge to all third
101 | parties under the terms of this License.
102 |
103 | c) If the modified program normally reads commands interactively
104 | when run, you must cause it, when started running for such
105 | interactive use in the most ordinary way, to print or display an
106 | announcement including an appropriate copyright notice and a
107 | notice that there is no warranty (or else, saying that you provide
108 | a warranty) and that users may redistribute the program under
109 | these conditions, and telling the user how to view a copy of this
110 | License. (Exception: if the Program itself is interactive but
111 | does not normally print such an announcement, your work based on
112 | the Program is not required to print an announcement.)
113 |
114 | These requirements apply to the modified work as a whole. If
115 | identifiable sections of that work are not derived from the Program,
116 | and can be reasonably considered independent and separate works in
117 | themselves, then this License, and its terms, do not apply to those
118 | sections when you distribute them as separate works. But when you
119 | distribute the same sections as part of a whole which is a work based
120 | on the Program, the distribution of the whole must be on the terms of
121 | this License, whose permissions for other licensees extend to the
122 | entire whole, and thus to each and every part regardless of who wrote it.
123 |
124 | Thus, it is not the intent of this section to claim rights or contest
125 | your rights to work written entirely by you; rather, the intent is to
126 | exercise the right to control the distribution of derivative or
127 | collective works based on the Program.
128 |
129 | In addition, mere aggregation of another work not based on the Program
130 | with the Program (or with a work based on the Program) on a volume of
131 | a storage or distribution medium does not bring the other work under
132 | the scope of this License.
133 |
134 | 3. You may copy and distribute the Program (or a work based on it,
135 | under Section 2) in object code or executable form under the terms of
136 | Sections 1 and 2 above provided that you also do one of the following:
137 |
138 | a) Accompany it with the complete corresponding machine-readable
139 | source code, which must be distributed under the terms of Sections
140 | 1 and 2 above on a medium customarily used for software interchange; or,
141 |
142 | b) Accompany it with a written offer, valid for at least three
143 | years, to give any third party, for a charge no more than your
144 | cost of physically performing source distribution, a complete
145 | machine-readable copy of the corresponding source code, to be
146 | distributed under the terms of Sections 1 and 2 above on a medium
147 | customarily used for software interchange; or,
148 |
149 | c) Accompany it with the information you received as to the offer
150 | to distribute corresponding source code. (This alternative is
151 | allowed only for noncommercial distribution and only if you
152 | received the program in object code or executable form with such
153 | an offer, in accord with Subsection b above.)
154 |
155 | The source code for a work means the preferred form of the work for
156 | making modifications to it. For an executable work, complete source
157 | code means all the source code for all modules it contains, plus any
158 | associated interface definition files, plus the scripts used to
159 | control compilation and installation of the executable. However, as a
160 | special exception, the source code distributed need not include
161 | anything that is normally distributed (in either source or binary
162 | form) with the major components (compiler, kernel, and so on) of the
163 | operating system on which the executable runs, unless that component
164 | itself accompanies the executable.
165 |
166 | If distribution of executable or object code is made by offering
167 | access to copy from a designated place, then offering equivalent
168 | access to copy the source code from the same place counts as
169 | distribution of the source code, even though third parties are not
170 | compelled to copy the source along with the object code.
171 |
172 | 4. You may not copy, modify, sublicense, or distribute the Program
173 | except as expressly provided under this License. Any attempt
174 | otherwise to copy, modify, sublicense or distribute the Program is
175 | void, and will automatically terminate your rights under this License.
176 | However, parties who have received copies, or rights, from you under
177 | this License will not have their licenses terminated so long as such
178 | parties remain in full compliance.
179 |
180 | 5. You are not required to accept this License, since you have not
181 | signed it. However, nothing else grants you permission to modify or
182 | distribute the Program or its derivative works. These actions are
183 | prohibited by law if you do not accept this License. Therefore, by
184 | modifying or distributing the Program (or any work based on the
185 | Program), you indicate your acceptance of this License to do so, and
186 | all its terms and conditions for copying, distributing or modifying
187 | the Program or works based on it.
188 |
189 | 6. Each time you redistribute the Program (or any work based on the
190 | Program), the recipient automatically receives a license from the
191 | original licensor to copy, distribute or modify the Program subject to
192 | these terms and conditions. You may not impose any further
193 | restrictions on the recipients' exercise of the rights granted herein.
194 | You are not responsible for enforcing compliance by third parties to
195 | this License.
196 |
197 | 7. If, as a consequence of a court judgment or allegation of patent
198 | infringement or for any other reason (not limited to patent issues),
199 | conditions are imposed on you (whether by court order, agreement or
200 | otherwise) that contradict the conditions of this License, they do not
201 | excuse you from the conditions of this License. If you cannot
202 | distribute so as to satisfy simultaneously your obligations under this
203 | License and any other pertinent obligations, then as a consequence you
204 | may not distribute the Program at all. For example, if a patent
205 | license would not permit royalty-free redistribution of the Program by
206 | all those who receive copies directly or indirectly through you, then
207 | the only way you could satisfy both it and this License would be to
208 | refrain entirely from distribution of the Program.
209 |
210 | If any portion of this section is held invalid or unenforceable under
211 | any particular circumstance, the balance of the section is intended to
212 | apply and the section as a whole is intended to apply in other
213 | circumstances.
214 |
215 | It is not the purpose of this section to induce you to infringe any
216 | patents or other property right claims or to contest validity of any
217 | such claims; this section has the sole purpose of protecting the
218 | integrity of the free software distribution system, which is
219 | implemented by public license practices. Many people have made
220 | generous contributions to the wide range of software distributed
221 | through that system in reliance on consistent application of that
222 | system; it is up to the author/donor to decide if he or she is willing
223 | to distribute software through any other system and a licensee cannot
224 | impose that choice.
225 |
226 | This section is intended to make thoroughly clear what is believed to
227 | be a consequence of the rest of this License.
228 |
229 | 8. If the distribution and/or use of the Program is restricted in
230 | certain countries either by patents or by copyrighted interfaces, the
231 | original copyright holder who places the Program under this License
232 | may add an explicit geographical distribution limitation excluding
233 | those countries, so that distribution is permitted only in or among
234 | countries not thus excluded. In such case, this License incorporates
235 | the limitation as if written in the body of this License.
236 |
237 | 9. The Free Software Foundation may publish revised and/or new versions
238 | of the General Public License from time to time. Such new versions will
239 | be similar in spirit to the present version, but may differ in detail to
240 | address new problems or concerns.
241 |
242 | Each version is given a distinguishing version number. If the Program
243 | specifies a version number of this License which applies to it and "any
244 | later version", you have the option of following the terms and conditions
245 | either of that version or of any later version published by the Free
246 | Software Foundation. If the Program does not specify a version number of
247 | this License, you may choose any version ever published by the Free Software
248 | Foundation.
249 |
250 | 10. If you wish to incorporate parts of the Program into other free
251 | programs whose distribution conditions are different, write to the author
252 | to ask for permission. For software which is copyrighted by the Free
253 | Software Foundation, write to the Free Software Foundation; we sometimes
254 | make exceptions for this. Our decision will be guided by the two goals
255 | of preserving the free status of all derivatives of our free software and
256 | of promoting the sharing and reuse of software generally.
257 |
258 | NO WARRANTY
259 |
260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
268 | REPAIR OR CORRECTION.
269 |
270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
278 | POSSIBILITY OF SUCH DAMAGES.
279 |
280 | END OF TERMS AND CONDITIONS
281 |
282 | How to Apply These Terms to Your New Programs
283 |
284 | If you develop a new program, and you want it to be of the greatest
285 | possible use to the public, the best way to achieve this is to make it
286 | free software which everyone can redistribute and change under these terms.
287 |
288 | To do so, attach the following notices to the program. It is safest
289 | to attach them to the start of each source file to most effectively
290 | convey the exclusion of warranty; and each file should have at least
291 | the "copyright" line and a pointer to where the full notice is found.
292 |
293 |
294 | Copyright (C)
295 |
296 | This program is free software; you can redistribute it and/or modify
297 | it under the terms of the GNU General Public License as published by
298 | the Free Software Foundation; either version 2 of the License, or
299 | (at your option) any later version.
300 |
301 | This program is distributed in the hope that it will be useful,
302 | but WITHOUT ANY WARRANTY; without even the implied warranty of
303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
304 | GNU General Public License for more details.
305 |
306 | You should have received a copy of the GNU General Public License along
307 | with this program; if not, write to the Free Software Foundation, Inc.,
308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
309 |
310 | Also add information on how to contact you by electronic and paper mail.
311 |
312 | If the program is interactive, make it output a short notice like this
313 | when it starts in an interactive mode:
314 |
315 | Gnomovision version 69, Copyright (C) year name of author
316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
317 | This is free software, and you are welcome to redistribute it
318 | under certain conditions; type `show c' for details.
319 |
320 | The hypothetical commands `show w' and `show c' should show the appropriate
321 | parts of the General Public License. Of course, the commands you use may
322 | be called something other than `show w' and `show c'; they could even be
323 | mouse-clicks or menu items--whatever suits your program.
324 |
325 | You should also get your employer (if you work as a programmer) or your
326 | school, if any, to sign a "copyright disclaimer" for the program, if
327 | necessary. Here is a sample; alter the names:
328 |
329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program
330 | `Gnomovision' (which makes passes at compilers) written by James Hacker.
331 |
332 | , 1 April 1989
333 | Ty Coon, President of Vice
334 |
335 | This General Public License does not permit incorporating your program into
336 | proprietary programs. If your program is a subroutine library, you may
337 | consider it more useful to permit linking proprietary applications with the
338 | library. If this is what you want to do, use the GNU Lesser General
339 | Public License instead of this License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Edge-TTS for Android
2 |
3 | Edge-TTS for Android is a text-to-speech service that uses the Edge-TTS API to convert text to
4 | speech.
5 |
6 | ## Screenshots
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | /debug
3 | /release
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | id("com.google.devtools.ksp")
6 | }
7 |
8 | android {
9 | namespace = "com.istomyang.edgetss"
10 | compileSdk = 34
11 |
12 | defaultConfig {
13 | applicationId = "com.istomyang.edgetss"
14 | minSdk = 29
15 | targetSdk = 34
16 | versionCode = 1
17 | versionName = "1.5.3"
18 |
19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
20 | vectorDrawables {
21 | useSupportLibrary = true
22 | }
23 | }
24 |
25 | buildTypes {
26 | release {
27 | resValue("string", "app_name", "Edge TSS")
28 | applicationIdSuffix = ".release"
29 | isShrinkResources = true
30 | isMinifyEnabled = true
31 | proguardFiles(
32 | getDefaultProguardFile("proguard-android-optimize.txt"),
33 | "proguard-rules.pro"
34 | )
35 | signingConfig = signingConfigs.getByName("debug")
36 | }
37 | create("prerelease") {
38 | initWith(getByName("release"))
39 | resValue("string", "app_name", "Edge TSS β")
40 | applicationIdSuffix = ".prerelease"
41 | }
42 | debug {
43 | resValue("string", "app_name", "Edge TSS α")
44 | applicationIdSuffix = ".debug"
45 | }
46 | }
47 | compileOptions {
48 | sourceCompatibility = JavaVersion.VERSION_17
49 | targetCompatibility = JavaVersion.VERSION_17
50 | }
51 | kotlinOptions {
52 | jvmTarget = "17"
53 | }
54 | buildFeatures {
55 | compose = true
56 | }
57 | composeOptions {
58 | kotlinCompilerExtensionVersion = "1.5.1"
59 | }
60 | packaging {
61 | resources {
62 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
63 | }
64 | }
65 | }
66 |
67 | composeCompiler {
68 | reportsDestination = layout.buildDirectory.dir("compose_compiler")
69 | // stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
70 | }
71 |
72 | dependencies {
73 | implementation(project(":engine"))
74 |
75 | implementation(libs.junit.junit)
76 | val ktorVersion = "3.0.2"
77 | implementation("io.ktor:ktor-client-core:$ktorVersion")
78 | implementation("io.ktor:ktor-client-cio:$ktorVersion")
79 | implementation("io.ktor:ktor-client-websockets:$ktorVersion")
80 |
81 | implementation(libs.androidx.room.room.runtime2)
82 | implementation(libs.androidx.room.ktx)
83 | implementation(libs.androidx.datastore.core.android)
84 | implementation(libs.androidx.constraintlayout)
85 | implementation(libs.androidx.lifecycle.runtime.compose.android)
86 | testImplementation(libs.androidx.room.testing)
87 | ksp(libs.androidx.room.compiler)
88 |
89 | implementation(libs.androidx.datastore.preferences)
90 |
91 | implementation(libs.androidx.core.ktx)
92 | implementation(libs.androidx.lifecycle.runtime.ktx)
93 | implementation(libs.androidx.activity.compose)
94 | implementation(platform(libs.androidx.compose.bom))
95 | implementation(libs.androidx.ui)
96 | implementation(libs.androidx.ui.graphics)
97 | implementation(libs.androidx.ui.tooling.preview)
98 | implementation(libs.androidx.material3)
99 | testImplementation(libs.junit)
100 | androidTestImplementation(libs.androidx.junit)
101 | androidTestImplementation(libs.androidx.espresso.core)
102 | androidTestImplementation(platform(libs.androidx.compose.bom))
103 | androidTestImplementation(libs.androidx.ui.test.junit4)
104 | debugImplementation(libs.androidx.ui.tooling)
105 | debugImplementation(libs.androidx.ui.test.manifest)
106 |
107 | implementation(libs.androidx.lifecycle.viewmodel.compose)
108 | implementation(libs.material.icons.extended)
109 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/istomyang/edgetss/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.istomyang.edgetss", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/data/LogRepository.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.data
2 |
3 | import android.content.Context
4 | import androidx.annotation.GuardedBy
5 | import androidx.datastore.core.DataStore
6 | import androidx.datastore.preferences.core.Preferences
7 | import androidx.datastore.preferences.core.booleanPreferencesKey
8 | import androidx.datastore.preferences.core.edit
9 | import androidx.datastore.preferences.preferencesDataStore
10 | import androidx.room.ColumnInfo
11 | import androidx.room.Dao
12 | import androidx.room.Database
13 | import androidx.room.Entity
14 | import androidx.room.Insert
15 | import androidx.room.PrimaryKey
16 | import androidx.room.Query
17 | import androidx.room.Room
18 | import androidx.room.RoomDatabase
19 | import kotlinx.coroutines.CoroutineScope
20 | import kotlinx.coroutines.Dispatchers
21 | import kotlinx.coroutines.flow.first
22 | import kotlinx.coroutines.flow.map
23 | import kotlinx.coroutines.launch
24 | import java.time.LocalDateTime
25 | import java.time.ZoneOffset
26 | import kotlin.reflect.KProperty
27 |
28 | val Context.repositoryLog by LogRepositoryDelegate()
29 |
30 | class LogRepository(
31 | private val localDataSource: LogLocalDataSource,
32 | private val preferenceDataSource: DataStore
33 | ) {
34 | val enabled = preferenceDataSource.data.map { it[KEY_ENABLED] == true }
35 | val enabledDebug = preferenceDataSource.data.map { it[KEY_ENABLED_DEBUG] == true }
36 |
37 | suspend fun open(enabled: Boolean = true) {
38 | preferenceDataSource.edit {
39 | it[KEY_ENABLED] = enabled
40 | }
41 | }
42 |
43 | suspend fun openDebug(enabled: Boolean = true) {
44 | preferenceDataSource.edit {
45 | it[KEY_ENABLED_DEBUG] = enabled
46 | }
47 | }
48 |
49 | private fun insert(domain: String, level: LogLevel, message: String) {
50 | CoroutineScope(Dispatchers.IO).launch {
51 | if (!enabled.first()) {
52 | return@launch
53 | }
54 | val log = Log(
55 | domain = domain,
56 | level = level.name,
57 | message = message,
58 | createdAt = timestampBefore(0, 0, 0)
59 | )
60 | localDataSource.dao.inert(log)
61 | }
62 | }
63 |
64 | fun info(domain: String, message: String) = insert(domain = domain, level = LogLevel.INFO, message = message)
65 |
66 | suspend fun debug(domain: String, message: String) {
67 | if (!enabledDebug.first()) {
68 | return
69 | }
70 | insert(domain = domain, level = LogLevel.DEBUG, message = message)
71 | }
72 |
73 | fun error(domain: String, message: String) = insert(domain = domain, level = LogLevel.ERROR, message = message)
74 |
75 | suspend fun query(levels: List, o: Int, l: Int) = localDataSource.dao.query(levels, o, l)
76 |
77 | suspend fun queryAll(levels: List) = localDataSource.dao.queryAll(levels)
78 |
79 | suspend fun clear() {
80 | localDataSource.dao.clear()
81 | }
82 |
83 | suspend fun clearDebugBefore1Hour() {
84 | val before = timestampBefore(0, 1, 0)
85 | localDataSource.dao.clearDebug(before)
86 | }
87 |
88 | companion object {
89 | fun create(context: Context): LogRepository {
90 | val db = Room.databaseBuilder(
91 | context,
92 | LogDatabase::class.java,
93 | "log"
94 | ).build()
95 | val localDS = LogLocalDataSource(db.logDao())
96 | val preferenceDS = context.dateStoreLog
97 | return LogRepository(localDS, preferenceDS)
98 | }
99 |
100 | fun timestampBefore(min: Long, hour: Long, day: Long): Long {
101 | val now = LocalDateTime.now()
102 | val targetDateTime = now.minusMinutes(min).minusHours(hour).minusDays(day)
103 | return targetDateTime.toInstant(ZoneOffset.UTC).toEpochMilli()
104 | }
105 |
106 | private val KEY_ENABLED = booleanPreferencesKey("enabled")
107 | private val KEY_ENABLED_DEBUG = booleanPreferencesKey("enabled-debug")
108 | }
109 | }
110 |
111 | private val Context.dateStoreLog by preferencesDataStore("log")
112 |
113 | class LogRepositoryDelegate {
114 | private val lock = Any()
115 |
116 | @GuardedBy("lock")
117 | @Volatile
118 | private var instance: LogRepository? = null
119 |
120 | operator fun getValue(thisRef: Context, property: KProperty<*>): LogRepository {
121 | return instance ?: synchronized(lock) {
122 | if (instance == null) {
123 | val applicationContext = thisRef.applicationContext
124 | instance = LogRepository.create(applicationContext)
125 | }
126 | instance!!
127 | }
128 | }
129 | }
130 |
131 | enum class LogLevel {
132 | DEBUG,
133 | INFO,
134 | ERROR
135 | }
136 |
137 | // region LogDataSource
138 |
139 | class LogLocalDataSource(val dao: LogDao)
140 |
141 | @Database(entities = [Log::class], version = 1, exportSchema = false)
142 | abstract class LogDatabase : RoomDatabase() {
143 | abstract fun logDao(): LogDao
144 | }
145 |
146 | @Dao
147 | interface LogDao {
148 | @Insert
149 | suspend fun insertBatch(logs: List)
150 |
151 | @Insert
152 | suspend fun inert(log: Log)
153 |
154 | @Query("SELECT * FROM log WHERE level IN (:levels) ORDER BY created_at ASC LIMIT :l OFFSET :o")
155 | suspend fun query(
156 | levels: List,
157 | o: Int = 0,
158 | l: Int = 100
159 | ): List
160 |
161 | @Query("SELECT * FROM log WHERE level IN (:levels) ORDER BY created_at ASC")
162 | suspend fun queryAll(
163 | levels: List,
164 | ): List
165 |
166 | @Query("DELETE FROM log")
167 | suspend fun clear()
168 |
169 | @Query("DELETE FROM log WHERE level = 'DEBUG' AND created_at < :before")
170 | suspend fun clearDebug(before: Long)
171 | }
172 |
173 | @Entity(tableName = "log", indices = [])
174 | data class Log(
175 | @PrimaryKey(autoGenerate = true) val id: Int = 0,
176 | @ColumnInfo(name = "domain") val domain: String,
177 | @ColumnInfo(name = "level") val level: String,
178 | @ColumnInfo(name = "message") val message: String,
179 | @ColumnInfo(name = "created_at") val createdAt: Long // in ms
180 | )
181 |
182 | // endregion
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/data/SpeakerRepository.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.data
2 |
3 | import android.content.Context
4 | import androidx.annotation.GuardedBy
5 | import androidx.datastore.core.DataStore
6 | import androidx.datastore.preferences.core.Preferences
7 | import androidx.datastore.preferences.core.booleanPreferencesKey
8 | import androidx.datastore.preferences.core.edit
9 | import androidx.datastore.preferences.core.stringPreferencesKey
10 | import androidx.datastore.preferences.preferencesDataStore
11 | import androidx.room.ColumnInfo
12 | import androidx.room.Dao
13 | import androidx.room.Database
14 | import androidx.room.Entity
15 | import androidx.room.Index
16 | import androidx.room.Insert
17 | import androidx.room.OnConflictStrategy
18 | import androidx.room.PrimaryKey
19 | import androidx.room.Query
20 | import androidx.room.Room
21 | import androidx.room.RoomDatabase
22 | import com.istomyang.tts_engine.SpeakerManager
23 | import kotlinx.coroutines.CoroutineScope
24 | import kotlinx.coroutines.Dispatchers
25 | import kotlinx.coroutines.flow.Flow
26 | import kotlinx.coroutines.flow.map
27 | import kotlinx.coroutines.launch
28 | import kotlin.reflect.KProperty
29 |
30 | val Context.repositorySpeaker by SpeakerRepositoryDelegate()
31 |
32 | class SpeakerRepository(
33 | private val localDS: SpeakerLocalDataSource,
34 | private val remoteDS: SpeakerRemoteDataSource,
35 | private val preferenceDataSource: DataStore
36 | ) {
37 | private lateinit var voices: List
38 |
39 | suspend fun fetchAll(): Result> {
40 | if (::voices.isInitialized) {
41 | return Result.success(voices)
42 | }
43 | val result = remoteDS.getAll()
44 | if (result.isSuccess) {
45 | voices = result.getOrNull()!!
46 | return Result.success(voices)
47 | }
48 | return Result.failure(result.exceptionOrNull()!!)
49 | }
50 |
51 | suspend fun get(id: String): Voice? {
52 | return localDS.dao.get(id)
53 | }
54 |
55 | fun getActiveFlow(): Flow {
56 | return localDS.dao.getActiveFlow()
57 | }
58 |
59 | suspend fun removeActive() {
60 | localDS.dao.removeActive()
61 | }
62 |
63 | suspend fun setActive(id: String) {
64 | localDS.dao.setActive(id)
65 | }
66 |
67 | suspend fun getActive(): Voice? {
68 | return localDS.dao.getActive()
69 | }
70 |
71 | fun getFlow(): Flow> {
72 | return localDS.dao.getFlow()
73 | }
74 |
75 | suspend fun insert(ids: Set) {
76 | if (!::voices.isInitialized) {
77 | return
78 | }
79 | val save = voices.filter { it.uid in ids }
80 | localDS.dao.inserts(save)
81 | }
82 |
83 | suspend fun delete(ids: Set) {
84 | localDS.dao.delete(ids)
85 | }
86 |
87 | fun audioFormat() = preferenceDataSource.data.map {
88 | it[KEY_AUDIO_FORMAT] ?: SpeakerManager.OutputFormat.Audio24Khz48KbitrateMonoMp3.value
89 | }
90 |
91 | suspend fun setAudioFormat(format: String) {
92 | preferenceDataSource.edit {
93 | it[KEY_AUDIO_FORMAT] = format
94 | }
95 | }
96 |
97 | companion object {
98 | fun create(context: Context): SpeakerRepository {
99 | val db = Room.databaseBuilder(
100 | context,
101 | VoiceDatabase::class.java,
102 | "voice"
103 | ).build()
104 | val localDS = SpeakerLocalDataSource(db.voiceDao())
105 | val remoteDS = SpeakerRemoteDataSource()
106 | val preferenceDS = context.dateStoreSpeakers
107 | cleanPreviousVersion(preferenceDS)
108 | return SpeakerRepository(localDS, remoteDS, preferenceDS)
109 | }
110 |
111 | private fun cleanPreviousVersion(ds: DataStore) {
112 | CoroutineScope(Dispatchers.IO).launch {
113 | val k1 = booleanPreferencesKey("use-flow")
114 | ds.edit {
115 | it.remove(k1)
116 | }
117 | }
118 | }
119 |
120 | private val KEY_AUDIO_FORMAT = stringPreferencesKey("audio-format")
121 | }
122 | }
123 |
124 | class SpeakerRepositoryDelegate {
125 | private val lock = Any()
126 |
127 | @GuardedBy("lock")
128 | @Volatile
129 | private var instance: SpeakerRepository? = null
130 |
131 | operator fun getValue(thisRef: Context, property: KProperty<*>): SpeakerRepository {
132 | return instance ?: synchronized(lock) {
133 | if (instance == null) {
134 | val applicationContext = thisRef.applicationContext
135 | instance = SpeakerRepository.create(applicationContext)
136 | }
137 | instance!!
138 | }
139 | }
140 | }
141 |
142 | // region SpeakerRemoteDataSource
143 |
144 | class SpeakerRemoteDataSource {
145 | suspend fun getAll(): Result> {
146 | SpeakerManager().list().onSuccess { items ->
147 | val ret = items.map { it ->
148 | Voice(
149 | uid = it.name,
150 | name = it.name,
151 | shortName = it.shortName,
152 | gender = it.gender,
153 | locale = it.locale,
154 | suggestedCodec = it.suggestedCodec,
155 | friendlyName = it.friendlyName,
156 | status = it.status,
157 | contentCategories = it.voiceTag.contentCategories.joinToString(", "),
158 | voicePersonalities = it.voiceTag.voicePersonalities.joinToString(", "),
159 | active = false
160 | )
161 | }
162 | return Result.success(ret)
163 | }.onFailure {
164 | return Result.failure(it)
165 | }
166 |
167 | return Result.failure(Throwable("error"))
168 | }
169 | }
170 |
171 | // endregion
172 |
173 | // region SpeakerLocalDataSource
174 |
175 | class SpeakerLocalDataSource(val dao: VoiceDao)
176 |
177 | @Database(entities = [Voice::class], version = 1, exportSchema = false)
178 | abstract class VoiceDatabase : RoomDatabase() {
179 | abstract fun voiceDao(): VoiceDao
180 | }
181 |
182 | @Dao
183 | interface VoiceDao {
184 | @Insert(onConflict = OnConflictStrategy.REPLACE)
185 | suspend fun inserts(voice: List)
186 |
187 | @Query("SELECT * FROM voice WHERE uid = :id")
188 | suspend fun get(id: String): Voice?
189 |
190 | @Query("SELECT * FROM voice WHERE uid IN (:ids)")
191 | suspend fun getByIds(ids: Set): List
192 |
193 | @Query("SELECT * FROM voice")
194 | suspend fun getAll(): List
195 |
196 | @Query("DELETE FROM voice WHERE uid IN (:ids)")
197 | suspend fun delete(ids: Set)
198 |
199 | @Query("SELECT * FROM voice")
200 | fun getFlow(): Flow>
201 |
202 | @Query("SELECT * FROM voice WHERE active = 1")
203 | fun getActiveFlow(): Flow
204 |
205 | @Query("SELECT * FROM voice WHERE active = 1")
206 | suspend fun getActive(): Voice?
207 |
208 | @Query("UPDATE voice SET active = 1 WHERE uid = :id")
209 | suspend fun setActive(id: String)
210 |
211 | @Query("UPDATE voice SET active = 0 WHERE active = 1")
212 | suspend fun removeActive()
213 | }
214 |
215 | @Entity(tableName = "voice", indices = [Index("locale")])
216 | data class Voice(
217 | @PrimaryKey val uid: String,
218 | @ColumnInfo(name = "active") val active: Boolean,
219 | @ColumnInfo(name = "name") val name: String,
220 | @ColumnInfo(name = "short_name") val shortName: String,
221 | @ColumnInfo(name = "gender") val gender: String,
222 | @ColumnInfo(name = "locale") val locale: String,
223 | @ColumnInfo(name = "suggested_codec") val suggestedCodec: String,
224 | @ColumnInfo(name = "friendly_name") val friendlyName: String,
225 | @ColumnInfo(name = "status") val status: String,
226 | @ColumnInfo(name = "content_categories") val contentCategories: String, // split by ,
227 | @ColumnInfo(name = "voice_personalities") val voicePersonalities: String,
228 | )
229 |
230 | // endregion
231 |
232 |
233 | private val Context.dateStoreSpeakers by preferencesDataStore("speakers")
234 |
235 |
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/service/EdgeTTSService.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.service
2 |
3 | import android.media.AudioFormat
4 | import android.speech.tts.SynthesisCallback
5 | import android.speech.tts.SynthesisRequest
6 | import android.speech.tts.TextToSpeech
7 | import android.speech.tts.TextToSpeechService
8 | import android.util.Log
9 | import com.istomyang.edgetss.data.LogRepository
10 | import com.istomyang.edgetss.data.SpeakerRepository
11 | import com.istomyang.edgetss.data.repositoryLog
12 | import com.istomyang.edgetss.data.repositorySpeaker
13 | import com.istomyang.edgetss.utils.Player
14 | import com.istomyang.tts_engine.TTS
15 | import kotlinx.coroutines.CancellationException
16 | import kotlinx.coroutines.CoroutineScope
17 | import kotlinx.coroutines.Dispatchers
18 | import kotlinx.coroutines.cancel
19 | import kotlinx.coroutines.channels.Channel
20 | import kotlinx.coroutines.flow.Flow
21 | import kotlinx.coroutines.flow.transform
22 | import kotlinx.coroutines.launch
23 | import kotlinx.coroutines.runBlocking
24 |
25 | class EdgeTTSService : TextToSpeechService() {
26 | companion object {
27 | private const val LOG_NAME = "EdgeTTSService"
28 | }
29 |
30 | private val scope = CoroutineScope(Dispatchers.IO)
31 |
32 | private lateinit var engine: TTS
33 | private lateinit var player: Player
34 | private lateinit var logRepository: LogRepository
35 | private lateinit var speakerRepository: SpeakerRepository
36 |
37 | private var prepared = false
38 | private var locale: String? = null
39 | private var voiceName: String? = null
40 | private var outputFormat: String? = null
41 | private var sampleRate = 24000
42 |
43 | override fun onCreate() {
44 | super.onCreate()
45 |
46 | val context = this.applicationContext
47 | logRepository = context.repositoryLog
48 | speakerRepository = context.repositorySpeaker
49 |
50 | engine = TTS()
51 | player = Player()
52 |
53 | scope.launch {
54 | launch { collectConfig() }
55 |
56 | launch { collectAudioFromEngine() }
57 |
58 | try {
59 | engine.run()
60 | } catch (_: CancellationException) {
61 | } catch (e: Throwable) {
62 | resultChannel.send(Result.failure(e)) // tell error occurs.
63 | error("engine run error: $e")
64 | }
65 | }
66 | }
67 |
68 | override fun onDestroy() {
69 | runBlocking {
70 | engine.close()
71 | }
72 | scope.cancel()
73 | super.onDestroy()
74 | }
75 |
76 | override fun onStop() {
77 | player.pause()
78 | }
79 |
80 | private suspend fun collectConfig() {
81 | speakerRepository.getActiveFlow().collect { voice ->
82 | if (voice != null) {
83 | locale = voice.locale
84 | voiceName = voice.name
85 | outputFormat = voice.suggestedCodec
86 | prepared = true
87 | info("use speaker: $voiceName - $locale")
88 | }
89 | }
90 | }
91 |
92 | override fun onIsLanguageAvailable(lang: String?, country: String?, variant: String?): Int {
93 | return TextToSpeech.LANG_AVAILABLE
94 | }
95 |
96 | override fun onLoadLanguage(lang: String?, country: String?, variant: String?): Int {
97 | return TextToSpeech.LANG_AVAILABLE
98 | }
99 |
100 | override fun onGetLanguage(): Array {
101 | return arrayOf("", "", "")
102 | }
103 |
104 | private val resultChannel = Channel>()
105 |
106 | private fun collectAudioFromEngine() = scope.launch {
107 | engine.output().transform { frame ->
108 | if (frame.audioCompleted) {
109 | emit(Player.Frame(null, endOfFrame = true))
110 | return@transform
111 | }
112 | if (frame.textCompleted) {
113 | return@transform
114 | }
115 | emit(Player.Frame(frame.data))
116 | }.play {
117 | resultChannel.send(Result.success(Unit))
118 | }
119 | }
120 |
121 | private suspend fun Flow.play(onCompleted: suspend () -> Unit) = player.run(this, onCompleted)
122 |
123 | override fun onSynthesizeText(request: SynthesisRequest?, callback: SynthesisCallback?) {
124 | if (request == null || callback == null || !prepared) {
125 | return
126 | }
127 |
128 | val text = request.charSequenceText.toString()
129 | val pitch = request.pitch - 100
130 | val rate = request.speechRate - 100
131 |
132 | info("start synthesizing text: $text")
133 |
134 | runBlocking {
135 | callback.start(sampleRate, AudioFormat.ENCODING_PCM_16BIT, 1)
136 |
137 | player.play()
138 |
139 | // 1. input text
140 | val metadata = TTS.AudioMetaData(
141 | locale = locale!!,
142 | voiceName = voiceName!!,
143 | volume = "+0%",
144 | outputFormat = outputFormat!!,
145 | pitch = "${pitch}Hz",
146 | rate = "${rate}%",
147 | )
148 | try {
149 | engine.input(text, metadata)
150 | } catch (e: Throwable) {
151 | callback.error()
152 | error("synthesize text error: $e")
153 | return@runBlocking
154 | }
155 |
156 | // 2. wait result
157 | for (result in resultChannel) {
158 | when {
159 | result.isSuccess -> {
160 | callback.done()
161 | break
162 | }
163 |
164 | result.isFailure -> {
165 | callback.error()
166 | break
167 | }
168 | }
169 | }
170 | }
171 | }
172 |
173 | private fun debug(message: String) {
174 | Log.d(LOG_NAME, message)
175 | scope.launch {
176 | logRepository.debug(LOG_NAME, message)
177 | }
178 | }
179 |
180 | private fun info(message: String) {
181 | logRepository.info(LOG_NAME, message)
182 | Log.i(LOG_NAME, message)
183 | }
184 |
185 | private fun error(message: String) {
186 | logRepository.error(LOG_NAME, message)
187 | Log.e(LOG_NAME, message)
188 | }
189 |
190 | private val String.description: String
191 | get() {
192 | val size = this.length
193 | return if (size > 10) {
194 | "${this.substring(0, 10)}..."
195 | } else {
196 | this
197 | }
198 | }
199 | }
200 |
201 |
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.runtime.CompositionLocalProvider
8 | import androidx.compose.ui.platform.LocalLifecycleOwner
9 | import com.istomyang.edgetss.ui.main.MainContent
10 | import com.istomyang.edgetss.ui.theme.EdgeTSSTheme
11 |
12 | class MainActivity : ComponentActivity() {
13 | override fun onCreate(savedInstanceState: Bundle?) {
14 | super.onCreate(savedInstanceState)
15 | enableEdgeToEdge()
16 | setContent {
17 | CompositionLocalProvider(LocalLifecycleOwner provides this) {
18 | EdgeTSSTheme {
19 | MainContent()
20 | }
21 | }
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/main/LogView.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.main
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.size
9 | import androidx.compose.foundation.lazy.LazyColumn
10 | import androidx.compose.foundation.lazy.items
11 | import androidx.compose.foundation.lazy.rememberLazyListState
12 | import androidx.compose.material.icons.Icons
13 | import androidx.compose.material.icons.filled.BugReport
14 | import androidx.compose.material.icons.filled.CleaningServices
15 | import androidx.compose.material.icons.filled.Description
16 | import androidx.compose.material.icons.filled.FilterAlt
17 | import androidx.compose.material.icons.filled.Menu
18 | import androidx.compose.material.icons.filled.ToggleOff
19 | import androidx.compose.material.icons.filled.ToggleOn
20 | import androidx.compose.material3.BottomAppBar
21 | import androidx.compose.material3.DropdownMenu
22 | import androidx.compose.material3.DropdownMenuItem
23 | import androidx.compose.material3.ExperimentalMaterial3Api
24 | import androidx.compose.material3.MaterialTheme
25 | import androidx.compose.material3.Scaffold
26 | import androidx.compose.material3.Text
27 | import androidx.compose.material3.TopAppBar
28 | import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
29 | import androidx.compose.runtime.Composable
30 | import androidx.compose.runtime.LaunchedEffect
31 | import androidx.compose.runtime.getValue
32 | import androidx.compose.runtime.mutableStateOf
33 | import androidx.compose.runtime.remember
34 | import androidx.compose.runtime.setValue
35 | import androidx.compose.ui.Alignment
36 | import androidx.compose.ui.Modifier
37 | import androidx.compose.ui.unit.dp
38 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
39 | import androidx.lifecycle.viewmodel.compose.viewModel
40 | import com.istomyang.edgetss.data.LogLevel
41 | import com.istomyang.edgetss.ui.main.component.IconButton2
42 |
43 | /**
44 | * LogScreen is a top level [Screen] config for [MainContent].
45 | */
46 | val LogScreen = Screen(title = "Log", icon = Icons.Filled.Description) { openDrawer ->
47 | LogContentView(openDrawer)
48 | }
49 |
50 | @OptIn(ExperimentalMaterial3Api::class)
51 | @Composable
52 | private fun LogContentView(openDrawer: () -> Unit) {
53 | val viewModel: LogViewModel = viewModel(factory = LogViewModel.Factory)
54 |
55 | val lines by viewModel.linesUiState.collectAsStateWithLifecycle()
56 | val logOpened by viewModel.logOpened.collectAsStateWithLifecycle()
57 | val logDebugOpened by viewModel.logDebugOpened.collectAsStateWithLifecycle()
58 |
59 | LaunchedEffect(UInt) {
60 | viewModel.loadLogs()
61 | viewModel.collectLogs()
62 | }
63 |
64 | Scaffold(
65 | topBar = {
66 | TopAppBar(colors = topAppBarColors(
67 | containerColor = MaterialTheme.colorScheme.primaryContainer,
68 | titleContentColor = MaterialTheme.colorScheme.primary,
69 | ), title = {
70 | Text(text = "Log")
71 | }, navigationIcon = {
72 | IconButton2("Menu", Icons.Default.Menu) { openDrawer() }
73 | }, actions = {
74 | IconButton2(
75 | "Open Log",
76 | if (logOpened) Icons.Filled.ToggleOn else Icons.Filled.ToggleOff,
77 | modifier = Modifier.size(48.dp),
78 | tint = if (logOpened) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary
79 | ) {
80 | viewModel.openLog(!logOpened)
81 | }
82 | IconButton2(
83 | "Open Debug",
84 | Icons.Filled.BugReport,
85 | modifier = Modifier.size(32.dp),
86 | tint = if (logDebugOpened) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary
87 | ) {
88 | viewModel.openDebugLog(!logDebugOpened)
89 | }
90 | })
91 | }, bottomBar = {
92 | BottomAppBar(
93 | actions = {
94 | IconButton2("Clear", Icons.Default.CleaningServices) {
95 | viewModel.clearLog()
96 | }
97 | FilterView(
98 | options = listOf(
99 | MenuOption("All", "all"),
100 | MenuOption("Info", LogLevel.INFO.name),
101 | MenuOption("Debug", LogLevel.DEBUG.name),
102 | MenuOption("Error", LogLevel.ERROR.name),
103 | )
104 | ) {
105 | viewModel.setLogLevel(it.value)
106 | }
107 | }
108 | )
109 | }
110 | ) { innerPadding ->
111 | LogViewer(
112 | modifier = Modifier.padding(innerPadding),
113 | lines = lines
114 | )
115 | }
116 | }
117 |
118 | //@Preview(showBackground = true)
119 | @Composable
120 | private fun ContentViewPreview() {
121 | LogContentView(openDrawer = {})
122 | }
123 |
124 | @Composable
125 | private fun LogViewer(modifier: Modifier = Modifier, lines: List) {
126 | val lazyListState = rememberLazyListState()
127 |
128 | LaunchedEffect(lines.size) {
129 | if (lines.size > 1) {
130 | lazyListState.animateScrollToItem(lines.size - 1)
131 | }
132 | }
133 |
134 | LazyColumn(
135 | state = lazyListState,
136 | modifier = modifier
137 | .fillMaxSize()
138 | .padding(14.dp)
139 | ) {
140 | items(lines) { line ->
141 | Text(
142 | text = line,
143 | style = MaterialTheme.typography.bodySmall,
144 | modifier = Modifier
145 | .fillMaxWidth()
146 | .padding(bottom = 5.dp)
147 | )
148 | }
149 | }
150 | }
151 |
152 | //@Preview(showBackground = true)
153 | @Composable
154 | private fun LogViewerPreview() {
155 | val lines = (0..1000).map {
156 | "2023-01-01 00:00:00.000 [INFO] [Log] Hello, world!"
157 | }
158 |
159 | Column(
160 | modifier = Modifier
161 | .height(300.dp)
162 | .fillMaxWidth(),
163 | horizontalAlignment = Alignment.CenterHorizontally
164 | ) {
165 | LogViewer(lines = lines)
166 | }
167 | }
168 |
169 | @Composable
170 | private fun FilterView(
171 | options: List,
172 | onSelected: (MenuOption) -> Unit,
173 | ) {
174 | var expanded by remember { mutableStateOf(false) }
175 | Column {
176 | IconButton2("Level", Icons.Default.FilterAlt) {
177 | expanded = !expanded
178 | }
179 | DropdownMenu(
180 | expanded = expanded,
181 | onDismissRequest = { expanded = false }
182 | ) {
183 | options.forEach { option ->
184 | DropdownMenuItem(
185 | text = { Text(text = option.title) },
186 | onClick = {
187 | onSelected(option)
188 | expanded = false
189 | }
190 | )
191 | }
192 | }
193 | }
194 | }
195 |
196 | private data class MenuOption(val title: String, val value: String)
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/main/LogViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.main
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.ViewModelProvider
5 | import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
6 | import androidx.lifecycle.viewModelScope
7 | import androidx.lifecycle.viewmodel.initializer
8 | import androidx.lifecycle.viewmodel.viewModelFactory
9 | import com.istomyang.edgetss.data.Log
10 | import com.istomyang.edgetss.data.LogLevel
11 | import com.istomyang.edgetss.data.LogRepository
12 | import com.istomyang.edgetss.data.repositoryLog
13 | import kotlinx.coroutines.cancel
14 | import kotlinx.coroutines.delay
15 | import kotlinx.coroutines.flow.MutableStateFlow
16 | import kotlinx.coroutines.flow.SharingStarted
17 | import kotlinx.coroutines.flow.StateFlow
18 | import kotlinx.coroutines.flow.asStateFlow
19 | import kotlinx.coroutines.flow.stateIn
20 | import kotlinx.coroutines.flow.update
21 | import kotlinx.coroutines.launch
22 | import java.time.Instant
23 | import java.time.LocalDateTime
24 | import java.time.ZoneId
25 | import java.time.format.DateTimeFormatter
26 |
27 | class LogViewModel(
28 | val logRepository: LogRepository,
29 | ) : ViewModel() {
30 |
31 | private val _linesUiState = MutableStateFlow(emptyList())
32 | val linesUiState: StateFlow> = _linesUiState.asStateFlow()
33 |
34 | private val logLevel = MutableStateFlow("all")
35 |
36 | fun loadLogs() {
37 | viewModelScope.launch {
38 | val levels = getLevels()
39 | val data = logRepository.queryAll(levels)
40 | updateLines(data)
41 | }
42 | }
43 |
44 | fun collectLogs() {
45 | viewModelScope.launch {
46 | while (true) {
47 | delay(1000)
48 | val o = _linesUiState.value.count()
49 | val levels = getLevels()
50 | val data = logRepository.query(levels, o, 50)
51 | updateLines(data)
52 | }
53 | }
54 | }
55 |
56 | private fun getLevels(): List {
57 | return if (logLevel.value == "all") {
58 | listOf(LogLevel.INFO.name, LogLevel.DEBUG.name, LogLevel.ERROR.name)
59 | } else {
60 | listOf(logLevel.value)
61 | }
62 | }
63 |
64 | private fun updateLines(newLines: List) {
65 | _linesUiState.update { it + newLines.map { log -> "${ts2DateTime(log.createdAt)} ${log.level} ${log.domain}: ${log.message}" } }
66 | }
67 |
68 | fun setLogLevel(level: String) {
69 | logLevel.value = level
70 | _linesUiState.update { emptyList() } // clear screen.
71 | loadLogs()
72 | }
73 |
74 | fun openLog(b: Boolean) {
75 | viewModelScope.launch {
76 | logRepository.open(b)
77 | }
78 | }
79 |
80 | fun openDebugLog(b: Boolean) {
81 | viewModelScope.launch {
82 | logRepository.openDebug(b)
83 | }
84 | }
85 |
86 | fun clearLog() {
87 | viewModelScope.launch {
88 | _linesUiState.update { emptyList() }
89 | logRepository.clear()
90 | }
91 | }
92 |
93 | val logOpened: StateFlow = logRepository.enabled.stateIn(
94 | scope = viewModelScope,
95 | started = SharingStarted.WhileSubscribed(),
96 | initialValue = false
97 | )
98 |
99 | val logDebugOpened: StateFlow = logRepository.enabledDebug.stateIn(
100 | scope = viewModelScope,
101 | started = SharingStarted.WhileSubscribed(),
102 | initialValue = false
103 | )
104 |
105 | override fun onCleared() {
106 | super.onCleared()
107 | viewModelScope.cancel()
108 | }
109 |
110 | private fun ts2DateTime(ts: Long): String {
111 | return LocalDateTime.ofInstant(
112 | Instant.ofEpochMilli(ts),
113 | ZoneId.systemDefault()
114 | ).format(
115 | DateTimeFormatter.ofPattern("MM-dd-HH:mm:ss")
116 | )
117 | }
118 |
119 | companion object {
120 | val Factory: ViewModelProvider.Factory = viewModelFactory {
121 | initializer {
122 | val context = this[APPLICATION_KEY]!!.applicationContext
123 | LogViewModel(context.repositoryLog)
124 | }
125 | }
126 | }
127 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/main/MainView.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.main
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.Spacer
5 | import androidx.compose.foundation.layout.height
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.rememberScrollState
8 | import androidx.compose.foundation.verticalScroll
9 | import androidx.compose.material3.DrawerValue
10 | import androidx.compose.material3.Icon
11 | import androidx.compose.material3.ModalDrawerSheet
12 | import androidx.compose.material3.ModalNavigationDrawer
13 | import androidx.compose.material3.NavigationDrawerItem
14 | import androidx.compose.material3.NavigationDrawerItemDefaults
15 | import androidx.compose.material3.Text
16 | import androidx.compose.material3.rememberDrawerState
17 | import androidx.compose.runtime.Composable
18 | import androidx.compose.runtime.mutableStateOf
19 | import androidx.compose.runtime.remember
20 | import androidx.compose.runtime.rememberCoroutineScope
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.graphics.vector.ImageVector
23 | import androidx.compose.ui.tooling.preview.Preview
24 | import androidx.compose.ui.unit.dp
25 | import kotlinx.coroutines.launch
26 |
27 |
28 | /**
29 | * Screen defines a item for [ModalNavigationDrawer] in [MainContent].
30 | */
31 | data class Screen(
32 | val title: String,
33 | val icon: ImageVector,
34 | val makeContent: @Composable (openDrawer: () -> Unit) -> Unit
35 | )
36 |
37 | @Composable
38 | fun MainContent() {
39 | val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
40 | val scope = rememberCoroutineScope()
41 |
42 | val screens: List = listOf(SpeakerScreen, LogScreen)
43 | val selectedScreen = remember { mutableStateOf(screens[0]) }
44 |
45 | // The reason I don't use [NavigationBar] is that [Scaffold] needs to be in the parent
46 | // layer, which breaks the connection between [TopAppBar] and [ContentView],
47 | // which is a complex but not necessary design.
48 | // In this app, Speaker is a main and unique part, and [NavigationBar] which I think is
49 | // putting equally important part in a one layer. Based on this, I think complex sibling
50 | // component interactions are necessary.
51 | ModalNavigationDrawer(
52 | drawerState = drawerState,
53 | drawerContent = {
54 | ModalDrawerSheet {
55 | Column(Modifier.verticalScroll(rememberScrollState())) {
56 | Spacer(Modifier.height(12.dp))
57 | screens.forEach { screen ->
58 | NavigationDrawerItem(
59 | icon = { Icon(screen.icon, contentDescription = null) },
60 | label = { Text(screen.title) },
61 | selected = false,
62 | onClick = {
63 | scope.launch { drawerState.close() }
64 | selectedScreen.value = screen
65 | },
66 | modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
67 | )
68 | }
69 | }
70 | }
71 | },
72 | ) {
73 | selectedScreen.value.makeContent { scope.launch { drawerState.open() } }
74 | }
75 | }
76 |
77 |
78 | @Preview(showBackground = true)
79 | @Composable
80 | fun MainContentPreview() {
81 | MainContent()
82 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/main/SpeakerView.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.main
2 |
3 | import android.util.Log
4 | import androidx.compose.foundation.Image
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.layout.Arrangement
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Row
9 | import androidx.compose.foundation.layout.Spacer
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.heightIn
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.layout.size
14 | import androidx.compose.foundation.lazy.LazyColumn
15 | import androidx.compose.foundation.lazy.items
16 | import androidx.compose.foundation.shape.RoundedCornerShape
17 | import androidx.compose.material.icons.Icons
18 | import androidx.compose.material.icons.filled.Add
19 | import androidx.compose.material.icons.filled.Check
20 | import androidx.compose.material.icons.filled.Close
21 | import androidx.compose.material.icons.filled.Delete
22 | import androidx.compose.material.icons.filled.Edit
23 | import androidx.compose.material.icons.filled.Female
24 | import androidx.compose.material.icons.filled.Male
25 | import androidx.compose.material.icons.filled.Menu
26 | import androidx.compose.material.icons.filled.RadioButtonChecked
27 | import androidx.compose.material.icons.filled.RadioButtonUnchecked
28 | import androidx.compose.material.icons.filled.RecentActors
29 | import androidx.compose.material.icons.filled.Search
30 | import androidx.compose.material3.Card
31 | import androidx.compose.material3.Checkbox
32 | import androidx.compose.material3.CircularProgressIndicator
33 | import androidx.compose.material3.ExperimentalMaterial3Api
34 | import androidx.compose.material3.HorizontalDivider
35 | import androidx.compose.material3.Icon
36 | import androidx.compose.material3.ListItem
37 | import androidx.compose.material3.LocalContentColor
38 | import androidx.compose.material3.MaterialTheme
39 | import androidx.compose.material3.Scaffold
40 | import androidx.compose.material3.SnackbarHost
41 | import androidx.compose.material3.SnackbarHostState
42 | import androidx.compose.material3.Text
43 | import androidx.compose.material3.TextButton
44 | import androidx.compose.material3.TextField
45 | import androidx.compose.material3.TopAppBar
46 | import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
47 | import androidx.compose.runtime.Composable
48 | import androidx.compose.runtime.LaunchedEffect
49 | import androidx.compose.runtime.getValue
50 | import androidx.compose.runtime.mutableStateListOf
51 | import androidx.compose.runtime.mutableStateOf
52 | import androidx.compose.runtime.remember
53 | import androidx.compose.runtime.rememberCoroutineScope
54 | import androidx.compose.runtime.setValue
55 | import androidx.compose.ui.Alignment
56 | import androidx.compose.ui.Modifier
57 | import androidx.compose.ui.tooling.preview.Preview
58 | import androidx.compose.ui.unit.dp
59 | import androidx.compose.ui.window.Dialog
60 | import androidx.compose.ui.window.DialogProperties
61 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
62 | import androidx.lifecycle.viewmodel.compose.viewModel
63 | import com.istomyang.edgetss.ui.main.component.IconButton2
64 | import com.istomyang.tts_engine.SpeakerManager
65 | import kotlinx.coroutines.launch
66 |
67 | /**
68 | * SpeakerScreen is a top level [Screen] config for [MainContent].
69 | */
70 | val SpeakerScreen = Screen(title = "Speaker", icon = Icons.Filled.RecentActors) { openDrawer ->
71 | SpeakerContentView(openDrawer)
72 | }
73 |
74 | @OptIn(ExperimentalMaterial3Api::class)
75 | @Composable
76 | private fun SpeakerContentView(openDrawer: () -> Unit) {
77 | val viewModel: SpeakerViewModel = viewModel(factory = SpeakerViewModel.Factory)
78 |
79 | val speakers by viewModel.speakerUiState.collectAsStateWithLifecycle()
80 | val voices by viewModel.voicesUiState.collectAsStateWithLifecycle()
81 | val message by viewModel.messageUiState.collectAsStateWithLifecycle()
82 | val settings by viewModel.settingsUiState.collectAsStateWithLifecycle()
83 |
84 | var openPicker by remember { mutableStateOf(false) }
85 | var editMode by remember { mutableStateOf(false) }
86 | val editItems = remember { mutableStateListOf() } // id
87 |
88 | var openSetting by remember { mutableStateOf(false) }
89 |
90 | val scope = rememberCoroutineScope()
91 | val snackBarHostState = remember { SnackbarHostState() }
92 |
93 | LaunchedEffect(message) {
94 | scope.launch {
95 | val message = message ?: return@launch
96 | val msg = if (message.error) {
97 | "Error: ${message.description}"
98 | } else {
99 | message.description
100 | }
101 | snackBarHostState.showSnackbar(message = msg)
102 | }
103 | }
104 |
105 | Scaffold(
106 | snackbarHost = {
107 | SnackbarHost(hostState = snackBarHostState)
108 | },
109 | topBar = {
110 | TopAppBar(colors = topAppBarColors(
111 | containerColor = MaterialTheme.colorScheme.primaryContainer,
112 | titleContentColor = MaterialTheme.colorScheme.primary,
113 | ), title = {
114 | Text(text = "Edge TSS")
115 | }, navigationIcon = {
116 | IconButton2("Menu", Icons.Default.Menu) { openDrawer() }
117 | }, actions = {
118 | when (editMode) {
119 | true -> {
120 | Text("${editItems.size} selected")
121 | IconButton2(
122 | "Delete", Icons.Filled.Delete
123 | ) {
124 | viewModel.removeSpeakers(editItems)
125 | }
126 | IconButton2("Cancel", Icons.Filled.Close) {
127 | editMode = false
128 | editItems.clear()
129 | }
130 | }
131 | false -> {
132 | // IconButton2("Open Settings", Icons.Filled.Settings) {
133 | // openSetting = true
134 | // }
135 | IconButton2("Add Items", Icons.Filled.Add) {
136 | openPicker = true
137 | viewModel.loadVoices()
138 | }
139 | IconButton2("Edit Items", Icons.Filled.Edit) {
140 | editMode = true
141 | }
142 | }
143 | }
144 | })
145 | },
146 | ) { innerPadding ->
147 | LazyColumn(
148 | modifier = Modifier.padding(innerPadding),
149 | verticalArrangement = Arrangement.spacedBy(5.dp),
150 | ) {
151 | items(speakers) { speaker ->
152 | Item(speaker = speaker,
153 | status = if (editMode) ItemStatus.EDIT else ItemStatus.VIEW,
154 | onSelected = { id ->
155 | when (editMode) {
156 | true -> {
157 | if (editItems.contains(id)) {
158 | editItems.remove(id)
159 | } else {
160 | editItems.add(id)
161 | }
162 | }
163 | false -> {
164 | viewModel.setActiveSpeaker(id)
165 | }
166 | }
167 | })
168 | }
169 | }
170 | }
171 |
172 | if (openPicker) {
173 | SpeakerPicker(
174 | data = voices,
175 | onConfirm = { ids ->
176 | openPicker = false
177 | viewModel.addSpeakers(ids)
178 | },
179 | onCancel = {
180 | openPicker = false
181 | }
182 | )
183 | }
184 |
185 | if (openSetting) {
186 | Settings(
187 | defaultValue = settings,
188 | onConfirm = {
189 | openSetting = false
190 | viewModel.setAudioFormat(it.format)
191 | },
192 | onCancel = {
193 | openSetting = false
194 | }
195 | )
196 | }
197 | }
198 |
199 | //@Preview(showBackground = true)
200 | @Composable
201 | private fun ContentViewPreview() {
202 | SpeakerContentView {}
203 | }
204 |
205 | // region ListItem
206 |
207 | private enum class ItemStatus {
208 | VIEW, EDIT
209 | }
210 |
211 | @Composable
212 | private fun Item(speaker: Speaker, status: ItemStatus, onSelected: (id: String) -> Unit) {
213 | var selected by remember { mutableStateOf(false) }
214 | var preMode by remember { mutableStateOf(status) }
215 |
216 | if (preMode != status) {
217 | preMode = status
218 | selected = false
219 | }
220 |
221 | ListItem(modifier = Modifier.clickable {
222 | selected = !selected
223 | onSelected(speaker.id)
224 | },
225 | headlineContent = { Text(speaker.name) },
226 | supportingContent = { Text(speaker.description) },
227 | trailingContent = { Text(speaker.locale) },
228 | leadingContent = {
229 | when (status) {
230 | ItemStatus.VIEW -> {
231 | Icon(
232 | if (speaker.active) Icons.Filled.Check else if (speaker.gender == "Male") Icons.Filled.Male else Icons.Filled.Female,
233 | contentDescription = "",
234 | tint = if (selected) MaterialTheme.colorScheme.primary else LocalContentColor.current
235 | )
236 | }
237 |
238 | ItemStatus.EDIT -> {
239 | Icon(
240 | if (selected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked,
241 | contentDescription = "",
242 | tint = MaterialTheme.colorScheme.primary
243 | )
244 | }
245 | }
246 | })
247 | }
248 |
249 | // endregion
250 |
251 | // region SpeakerPicker
252 |
253 | data class Option(val title: String, val value: String, val searchKey: String)
254 |
255 | @Composable
256 | private fun SpeakerPicker(
257 | modifier: Modifier = Modifier,
258 | data: List,
259 | onConfirm: (List) -> Unit,
260 | onCancel: () -> Unit = {},
261 | ) {
262 | var selected by remember { mutableStateOf(emptyList()) }
263 | var search by remember { mutableStateOf("") }
264 | var candidates by remember { mutableStateOf(emptyList()) }
265 |
266 | if (data.isNotEmpty() && candidates.isEmpty()) {
267 | candidates = data
268 | }
269 |
270 | Dialog(onDismissRequest = { onCancel() }, properties = DialogProperties(usePlatformDefaultWidth = true)) {
271 | Card(
272 | modifier = modifier
273 | .fillMaxWidth()
274 | .padding(12.dp),
275 | shape = RoundedCornerShape(16.dp),
276 | ) {
277 | Column(
278 | modifier = Modifier.fillMaxWidth(),
279 | verticalArrangement = Arrangement.Center,
280 | horizontalAlignment = Alignment.CenterHorizontally
281 | ) {
282 | Text(
283 | "Select Speaker",
284 | style = MaterialTheme.typography.titleMedium,
285 | modifier = Modifier.padding(8.dp)
286 | )
287 |
288 | HorizontalDivider()
289 |
290 | TextField(
291 | modifier = Modifier
292 | .fillMaxWidth()
293 | .padding(horizontal = 12.dp),
294 | singleLine = true,
295 | value = search,
296 | onValueChange = {
297 | search = it
298 | if (search.isEmpty()) {
299 | candidates = data
300 | return@TextField
301 | }
302 | val searches = search.split(" ")
303 | candidates = data.filter { v ->
304 | for (search in searches) {
305 | val contain = v.searchKey.contains(search, ignoreCase = true)
306 | if (!contain) {
307 | return@filter false
308 | }
309 | }
310 | return@filter true
311 | }
312 | },
313 | leadingIcon = {
314 | if (data.isEmpty()) {
315 | CircularProgressIndicator(
316 | modifier = Modifier.size(26.dp),
317 | color = MaterialTheme.colorScheme.secondary,
318 | trackColor = MaterialTheme.colorScheme.surfaceVariant,
319 | )
320 | } else {
321 | Image(imageVector = Icons.Filled.Search, contentDescription = "")
322 | }
323 | },
324 | )
325 |
326 | LazyColumn(modifier = modifier.heightIn(max = 400.dp)) {
327 | items(items = candidates) { voice ->
328 | Row(
329 | Modifier
330 | .fillMaxWidth()
331 | .clickable(
332 | enabled = true,
333 | onClick = {
334 | selected = if (selected.contains(voice.value)) {
335 | selected - voice.value
336 | } else {
337 | selected + voice.value
338 | }
339 | }
340 | )
341 | .padding(horizontal = 16.dp),
342 | verticalAlignment = Alignment.CenterVertically
343 | ) {
344 | Checkbox(
345 | checked = selected.contains(voice.value),
346 | onCheckedChange = null
347 | )
348 | Text(
349 | text = voice.title,
350 | style = MaterialTheme.typography.bodyLarge,
351 | modifier = Modifier.padding(start = 12.dp)
352 | )
353 | }
354 | }
355 | }
356 |
357 | HorizontalDivider()
358 |
359 | Row(
360 | modifier = Modifier.fillMaxWidth(),
361 | horizontalArrangement = Arrangement.Center,
362 | ) {
363 | TextButton(
364 | onClick = { onCancel() },
365 | modifier = Modifier.padding(8.dp),
366 | ) {
367 | Text("Cancel")
368 | }
369 |
370 | Spacer(modifier = Modifier.weight(1.0f))
371 |
372 | TextButton(
373 | onClick = {
374 | onConfirm(selected)
375 | },
376 | modifier = Modifier.padding(8.dp),
377 | ) {
378 | Text("Confirm")
379 | }
380 | }
381 | }
382 | }
383 | }
384 | }
385 |
386 |
387 | //@Preview(showBackground = true)
388 | @Composable
389 | private fun SpeakerPickerPreview() {
390 | SpeakerPicker(
391 | data = (0..300).map {
392 | Option(
393 | title = "Microsoft Server Speech Text to Speech Voice (en-US, AvaMultilingualNeural) $it",
394 | value = "speaker_$it",
395 | searchKey = ""
396 | )
397 | }, onConfirm = { it ->
398 | Log.d("SpeakerPicker", "Confirm $it")
399 | }, onCancel = {}
400 | )
401 | }
402 |
403 | // endregion
404 |
405 | // region Setting
406 |
407 |
408 | data class SettingsData(
409 | val format: String,
410 | )
411 |
412 | @Composable
413 | private fun Settings(
414 | modifier: Modifier = Modifier,
415 | defaultValue: SettingsData,
416 | onConfirm: (SettingsData) -> Unit,
417 | onCancel: () -> Unit = {},
418 | ) {
419 | var format by remember { mutableStateOf(defaultValue.format) }
420 |
421 | Dialog(onDismissRequest = { onCancel() }, properties = DialogProperties(usePlatformDefaultWidth = true)) {
422 | Card(
423 | modifier = modifier
424 | .fillMaxWidth()
425 | .padding(8.dp),
426 | shape = RoundedCornerShape(16.dp),
427 | ) {
428 | Column(
429 | modifier = Modifier.fillMaxWidth(),
430 | verticalArrangement = Arrangement.Center,
431 | horizontalAlignment = Alignment.CenterHorizontally
432 | ) {
433 | Text(
434 | "Setting",
435 | style = MaterialTheme.typography.headlineSmall,
436 | )
437 | HorizontalDivider()
438 |
439 | Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp)) {
440 | Text(
441 | "TTS Audio Format",
442 | style = MaterialTheme.typography.titleMedium
443 | )
444 |
445 |
446 | listOf(
447 | SpeakerManager.OutputFormat.Audio24Khz48KbitrateMonoMp3,
448 | SpeakerManager.OutputFormat.Audio24Khz96KbitrateMonoMp3,
449 | SpeakerManager.OutputFormat.Webm24Khz16BitMonoOpus,
450 | ).forEach {
451 | val value = it.value
452 | Row(
453 | Modifier
454 | .fillMaxWidth()
455 | .clickable(
456 | enabled = true,
457 | onClick = {
458 | format = value
459 | }
460 | )
461 | .padding(horizontal = 10.dp, vertical = 3.dp),
462 | verticalAlignment = Alignment.CenterVertically
463 | ) {
464 | Checkbox(
465 | checked = value == format,
466 | onCheckedChange = null
467 | )
468 | Text(
469 | text = value,
470 | style = MaterialTheme.typography.bodyMedium,
471 | modifier = Modifier.padding(start = 8.dp)
472 | )
473 | }
474 | }
475 |
476 | // Row(
477 | // modifier = Modifier
478 | // .fillMaxWidth()
479 | // .padding(top = 16.dp),
480 | // verticalAlignment = Alignment.CenterVertically
481 | // ) {
482 | // Column {
483 | // Text(
484 | // text = "Use Flow",
485 | // style = MaterialTheme.typography.titleMedium,
486 | // )
487 | // Text(
488 | // text = "Start playing audio after a while of downloading data.",
489 | // style = MaterialTheme.typography.bodySmall,
490 | // modifier = Modifier
491 | // .width(200.dp)
492 | // )
493 | // }
494 | // Spacer(modifier = Modifier.weight(1.0f))
495 | // Switch(
496 | // modifier = Modifier
497 | // .padding(start = 5.dp),
498 | // checked = useFlow,
499 | // onCheckedChange = { useFlow = it })
500 | // }
501 | }
502 |
503 | HorizontalDivider()
504 |
505 | Row(
506 | modifier = Modifier.fillMaxWidth(),
507 | horizontalArrangement = Arrangement.Center,
508 | ) {
509 | Spacer(modifier = Modifier.weight(1.0f))
510 |
511 | TextButton(
512 | onClick = { onCancel() },
513 | modifier = Modifier.padding(8.dp),
514 | ) {
515 | Text("Cancel")
516 | }
517 |
518 | TextButton(
519 | onClick = {
520 | val data = SettingsData(
521 | format = format,
522 | )
523 | onConfirm(data)
524 | },
525 | modifier = Modifier.padding(8.dp),
526 | ) {
527 | Text("Confirm")
528 | }
529 | }
530 | }
531 | }
532 | }
533 | }
534 |
535 | @Preview(showBackground = true)
536 | @Composable
537 | private fun SettingPreview() {
538 | Settings(defaultValue = SettingsData(""), onConfirm = {}, onCancel = {})
539 | }
540 |
541 | // endregion
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/main/SpeakerViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.main
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.ViewModelProvider
5 | import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
6 | import androidx.lifecycle.viewModelScope
7 | import androidx.lifecycle.viewmodel.initializer
8 | import androidx.lifecycle.viewmodel.viewModelFactory
9 | import com.istomyang.edgetss.data.SpeakerRepository
10 | import com.istomyang.edgetss.data.Voice
11 | import com.istomyang.edgetss.data.repositorySpeaker
12 | import kotlinx.coroutines.cancel
13 | import kotlinx.coroutines.flow.MutableStateFlow
14 | import kotlinx.coroutines.flow.SharingStarted
15 | import kotlinx.coroutines.flow.StateFlow
16 | import kotlinx.coroutines.flow.asStateFlow
17 | import kotlinx.coroutines.flow.combine
18 | import kotlinx.coroutines.flow.flowOf
19 | import kotlinx.coroutines.flow.stateIn
20 | import kotlinx.coroutines.flow.update
21 | import kotlinx.coroutines.launch
22 |
23 | class SpeakerViewModel(
24 | private val speakerRepository: SpeakerRepository
25 | ) : ViewModel() {
26 |
27 | val speakerUiState: StateFlow> = combine(
28 | speakerRepository.getActiveFlow(),
29 | speakerRepository.getFlow(),
30 | ) { voice, voices ->
31 | val id = voice?.uid ?: ""
32 | voices.map { Speaker.from(it, id) }
33 | }.stateIn(
34 | scope = viewModelScope,
35 | started = SharingStarted.WhileSubscribed(),
36 | initialValue = emptyList()
37 | )
38 |
39 | private val _voicesUiState = MutableStateFlow(emptyList())
40 | val voicesUiState: StateFlow> = _voicesUiState.asStateFlow()
41 |
42 | private val _messageUiState = MutableStateFlow(null as Message?)
43 | val messageUiState: StateFlow = _messageUiState.asStateFlow()
44 |
45 | fun loadVoices() {
46 | viewModelScope.launch {
47 | speakerRepository.fetchAll().onSuccess { voices ->
48 | _voicesUiState.update {
49 | voices.filter { voice ->
50 | !speakerUiState.value.any { voice.uid == it.id }
51 | }.map { voice2Option(it) }
52 | }
53 | }.onFailure { e ->
54 | _messageUiState.update { Message(e.localizedMessage ?: "", true) }
55 | }
56 | }
57 | }
58 |
59 |
60 | fun addSpeakers(ids: List) {
61 | viewModelScope.launch {
62 | speakerRepository.insert(ids.filter { id -> !speakerUiState.value.any { it.id == id } }.toSet())
63 | }
64 | }
65 |
66 | fun removeSpeakers(ids: List) {
67 | viewModelScope.launch {
68 | // handle active
69 | val activeId = speakerRepository.getActive()?.uid ?: ""
70 | if (activeId in ids) {
71 | speakerRepository.removeActive()
72 | }
73 | // delete
74 | speakerRepository.delete(ids.toSet())
75 | }
76 | }
77 |
78 | fun setActiveSpeaker(id: String) {
79 | viewModelScope.launch {
80 | speakerRepository.removeActive()
81 | speakerRepository.setActive(id)
82 | }
83 | }
84 |
85 | val settingsUiState: StateFlow = combine(
86 | speakerRepository.audioFormat(),
87 | flowOf("")
88 | ) { format, a ->
89 | SettingsData(format)
90 | }.stateIn(
91 | scope = viewModelScope,
92 | started = SharingStarted.WhileSubscribed(),
93 | initialValue = SettingsData("")
94 | )
95 |
96 | fun setAudioFormat(format: String) {
97 | viewModelScope.launch {
98 | speakerRepository.setAudioFormat(format)
99 | }
100 | }
101 |
102 | override fun onCleared() {
103 | super.onCleared()
104 | viewModelScope.cancel()
105 | }
106 |
107 | companion object {
108 | val Factory: ViewModelProvider.Factory = viewModelFactory {
109 | initializer {
110 | val context = this[APPLICATION_KEY]!!.applicationContext
111 | SpeakerViewModel(context.repositorySpeaker)
112 | }
113 | }
114 | }
115 | }
116 |
117 | data class Message(val description: String, val error: Boolean = false)
118 |
119 | data class Speaker(
120 | val id: String,
121 | val name: String,
122 | val gender: String,
123 | val locale: String,
124 | val description: String,
125 | var active: Boolean = false,
126 | ) {
127 | companion object {
128 | fun from(voice: Voice, activeId: String): Speaker {
129 | val description = voice.voicePersonalities + voice.contentCategories
130 | return Speaker(
131 | id = voice.name,
132 | name = extractVoiceName(voice),
133 | gender = voice.gender,
134 | locale = voice.locale,
135 | description = description,
136 | active = activeId == voice.name
137 | )
138 | }
139 | }
140 | }
141 |
142 | private fun voice2Option(voice: Voice): Option {
143 | val locale = voice.locale
144 | val name = extractVoiceName(voice)
145 | val gender = voice.gender
146 | val description = voice.contentCategories
147 | val title = "$name - $gender - $locale - $description"
148 | val searchKey = title.replace("[^a-zA-Z]".toRegex(), "")
149 | return Option(title, voice.name, searchKey)
150 | }
151 |
152 | // en-US-AvaMultilingualNeural 提取 AvaMultilingualNeural 删除 Neural
153 | private fun extractVoiceName(voice: Voice) = voice.shortName.split('-').last().replace("Neural", "")
154 |
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/main/component/Button.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.main.component
2 |
3 | import androidx.compose.material3.Icon
4 | import androidx.compose.material3.IconButton
5 | import androidx.compose.material3.LocalContentColor
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.graphics.Color
9 | import androidx.compose.ui.graphics.vector.ImageVector
10 |
11 | @Composable
12 | fun IconButton2(
13 | name: String,
14 | icon: ImageVector,
15 | modifier: Modifier = Modifier,
16 | tint: Color = LocalContentColor.current,
17 | onClick: () -> Unit
18 | ) {
19 | IconButton(onClick = {
20 | onClick()
21 | }) {
22 | Icon(
23 | modifier = modifier,
24 | imageVector = icon,
25 | contentDescription = name,
26 | tint = tint
27 | )
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/main/component/Picker.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.main.component
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.heightIn
9 | import androidx.compose.foundation.layout.size
10 | import androidx.compose.material.icons.Icons
11 | import androidx.compose.material.icons.filled.UnfoldLess
12 | import androidx.compose.material.icons.filled.UnfoldMore
13 | import androidx.compose.material3.CircularProgressIndicator
14 | import androidx.compose.material3.DropdownMenu
15 | import androidx.compose.material3.DropdownMenuItem
16 | import androidx.compose.material3.ExperimentalMaterial3Api
17 | import androidx.compose.material3.ExposedDropdownMenuBox
18 | import androidx.compose.material3.ExposedDropdownMenuDefaults
19 | import androidx.compose.material3.Icon
20 | import androidx.compose.material3.MaterialTheme
21 | import androidx.compose.material3.Text
22 | import androidx.compose.material3.TextField
23 | import androidx.compose.runtime.Composable
24 | import androidx.compose.runtime.LaunchedEffect
25 | import androidx.compose.runtime.getValue
26 | import androidx.compose.runtime.mutableStateOf
27 | import androidx.compose.runtime.remember
28 | import androidx.compose.runtime.setValue
29 | import androidx.compose.ui.Alignment
30 | import androidx.compose.ui.Modifier
31 | import androidx.compose.ui.draw.scale
32 | import androidx.compose.ui.graphics.vector.ImageVector
33 | import androidx.compose.ui.tooling.preview.Preview
34 | import androidx.compose.ui.unit.dp
35 |
36 |
37 | @OptIn(ExperimentalMaterial3Api::class)
38 | @Composable
39 | fun Picker(
40 | title: String,
41 | data: List,
42 | onSelected: (value: String) -> Unit,
43 | enable: Boolean = true
44 | ) {
45 | var expanded by remember { mutableStateOf(false) }
46 | var selected by remember { mutableStateOf(PickOption("", "")) }
47 |
48 | ExposedDropdownMenuBox(
49 | expanded = expanded,
50 | onExpandedChange = {
51 | if (data.isNotEmpty()) {
52 | expanded = !expanded
53 | }
54 | }
55 | ) {
56 | TextField(
57 | enabled = enable,
58 | value = selected.title,
59 | onValueChange = { },
60 | readOnly = true,
61 | label = { Text(title) },
62 | trailingIcon = {
63 | if (!enable) {
64 | return@TextField
65 | }
66 | if (data.isNotEmpty()) {
67 | ExposedDropdownMenuDefaults.TrailingIcon(
68 | expanded = expanded
69 | )
70 | } else {
71 | CircularProgressIndicator(
72 | modifier = Modifier
73 | .scale(0.6f),
74 | color = MaterialTheme.colorScheme.secondary,
75 | trackColor = MaterialTheme.colorScheme.surfaceVariant,
76 | )
77 | }
78 | },
79 | modifier = Modifier.menuAnchor(),
80 | )
81 | ExposedDropdownMenu(
82 | expanded = expanded,
83 | onDismissRequest = { expanded = false },
84 | modifier = Modifier.heightIn(max = 300.dp),
85 | ) {
86 | data.map {
87 | DropdownMenuItem(
88 | enabled = data.isNotEmpty(),
89 | text = { Text(text = it.title) },
90 | onClick = {
91 | selected = it
92 | expanded = false
93 | onSelected(it.value)
94 | }
95 | )
96 | }
97 | }
98 | }
99 | }
100 |
101 | data class PickOption(val title: String, val value: String)
102 |
103 |
104 | @Preview(showBackground = true)
105 | @Composable
106 | private fun PickerViewPreview() {
107 | var data by remember { mutableStateOf(emptyList()) }
108 |
109 | LaunchedEffect(UInt) {
110 | // delay(1000L)
111 | data = (0..10).map {
112 | PickOption("Option $it", "$it")
113 | }
114 | }
115 |
116 | Box(Modifier.size(300.dp)) {
117 | Column {
118 | Picker(
119 | title = "Select Option",
120 | data = data,
121 | onSelected = {},
122 | )
123 | Picker(
124 | title = "Select Option2",
125 | data = data,
126 | onSelected = {},
127 | )
128 | }
129 | }
130 | }
131 |
132 |
133 | data class OptionItem(val name: String, val value: String, val icon: ImageVector? = null)
134 |
135 | @Composable
136 | fun OptionPicker(
137 | modifier: Modifier = Modifier,
138 | default: String,
139 | options: List,
140 | onClick: (value: String) -> Unit
141 | ) {
142 | var expanded = remember { mutableStateOf(false) }
143 | var selected = remember { mutableStateOf(null) }
144 | Column(modifier = modifier, verticalArrangement = Arrangement.Center) {
145 | DropdownMenu(
146 | expanded = expanded.value,
147 | onDismissRequest = { expanded.value = false }) {
148 | options.forEach { option ->
149 | DropdownMenuItem(
150 | text = { Text(option.name) },
151 | onClick = {
152 | expanded.value = false
153 | selected.value = option
154 | onClick(option.value)
155 | },
156 | leadingIcon = (if (option.icon != null) {
157 | Icon(option.icon, contentDescription = null)
158 | } else {
159 | null
160 | }) as @Composable (() -> Unit)?
161 | )
162 | }
163 | }
164 | Row(
165 | modifier = Modifier.clickable(onClick = { expanded.value = !expanded.value }),
166 | verticalAlignment = Alignment.CenterVertically
167 | ) {
168 | Text(text = selected.value?.name ?: default, fontStyle = MaterialTheme.typography.titleLarge.fontStyle)
169 | Icon(
170 | imageVector = if (expanded.value) Icons.Filled.UnfoldLess else Icons.Filled.UnfoldMore,
171 | contentDescription = null,
172 | )
173 | }
174 | }
175 | }
176 |
177 | @Preview(showBackground = true)
178 | @Composable
179 | private fun OptionPickerPreview() {
180 | Column(
181 | modifier = Modifier.size(300.dp)
182 | ) {
183 | OptionPicker(
184 | default = "Default",
185 | options = listOf(
186 | OptionItem("Option1", "value1"),
187 | OptionItem("Option2", "value2"),
188 | OptionItem("Option3", "value3"),
189 | ),
190 | onClick = {}
191 | )
192 | }
193 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.theme
2 |
3 | import android.app.Activity
4 | import android.os.Build
5 | import androidx.compose.foundation.isSystemInDarkTheme
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.darkColorScheme
8 | import androidx.compose.material3.dynamicDarkColorScheme
9 | import androidx.compose.material3.dynamicLightColorScheme
10 | import androidx.compose.material3.lightColorScheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.platform.LocalContext
13 |
14 | private val DarkColorScheme = darkColorScheme(
15 | primary = Purple80,
16 | secondary = PurpleGrey80,
17 | tertiary = Pink80
18 | )
19 |
20 | private val LightColorScheme = lightColorScheme(
21 | primary = Purple40,
22 | secondary = PurpleGrey40,
23 | tertiary = Pink40
24 |
25 | /* Other default colors to override
26 | background = Color(0xFFFFFBFE),
27 | surface = Color(0xFFFFFBFE),
28 | onPrimary = Color.White,
29 | onSecondary = Color.White,
30 | onTertiary = Color.White,
31 | onBackground = Color(0xFF1C1B1F),
32 | onSurface = Color(0xFF1C1B1F),
33 | */
34 | )
35 |
36 | @Composable
37 | fun EdgeTSSTheme(
38 | darkTheme: Boolean = isSystemInDarkTheme(),
39 | // Dynamic color is available on Android 12+
40 | dynamicColor: Boolean = true,
41 | content: @Composable () -> Unit
42 | ) {
43 | val colorScheme = when {
44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
45 | val context = LocalContext.current
46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
47 | }
48 |
49 | darkTheme -> DarkColorScheme
50 | else -> LightColorScheme
51 | }
52 |
53 | MaterialTheme(
54 | colorScheme = colorScheme,
55 | typography = Typography,
56 | content = content
57 | )
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/utils/Codec.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.utils
2 |
3 | import android.content.Context
4 | import android.media.MediaCodec
5 | import android.media.MediaDataSource
6 | import android.media.MediaExtractor
7 | import android.media.MediaFormat
8 | import android.util.Log
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.Dispatchers
11 | import kotlinx.coroutines.channels.Channel
12 | import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
13 | import kotlinx.coroutines.delay
14 | import kotlinx.coroutines.flow.Flow
15 | import kotlinx.coroutines.flow.consumeAsFlow
16 | import kotlinx.coroutines.flow.flow
17 | import kotlinx.coroutines.flow.onCompletion
18 | import kotlinx.coroutines.launch
19 | import kotlinx.coroutines.sync.Mutex
20 | import kotlinx.coroutines.sync.withLock
21 | import java.nio.ByteBuffer
22 | import kotlin.coroutines.CoroutineContext
23 |
24 | private fun debug(msg: String) {
25 | Log.d("EdgeTTSService Codec", msg)
26 | }
27 |
28 | class Codec(private val source: Flow , private val context: Context) {
29 |
30 | class Frame(val data: ByteArray?, val endOfFrame: Boolean = false)
31 |
32 | fun run(context: CoroutineContext): Flow = flow {
33 | val dataChannel = Channel>()
34 | val resultChannel = Channel() // null is eof of frame.
35 |
36 | var pkgSendCount = 0
37 | var pkgReceiveCount = 0
38 | var mut = Mutex()
39 |
40 | CoroutineScope(context).launch {
41 | var ch: Channel? = null
42 | source.onCompletion {
43 | dataChannel.close() // wait decode.
44 | }.collect { frame ->
45 | if (frame.endOfFrame) {
46 | mut.withLock {
47 | debug("send pkg: $pkgSendCount")
48 | }
49 | ch?.close()
50 | ch = null
51 | return@collect
52 | }
53 | if (ch == null) {
54 | ch = Channel(UNLIMITED)
55 | dataChannel.send(ch!!.consumeAsFlow())
56 | }
57 | mut.withLock {
58 | pkgSendCount++
59 | }
60 | ch!!.send(frame.data!!)
61 | }
62 | }
63 |
64 | CoroutineScope(context).launch {
65 | var error: Throwable? = null
66 | try {
67 | for (src in dataChannel) {
68 | // src drain when frame is eof.
69 | decode(src).onCompletion {
70 | resultChannel.send(null)
71 | }.collect {
72 | mut.withLock {
73 | pkgReceiveCount += 1
74 | }
75 | resultChannel.send(it)
76 | }
77 |
78 | mut.withLock {
79 | debug("receive pkg: $pkgReceiveCount a: ${pkgReceiveCount / pkgSendCount}")
80 | pkgReceiveCount = 0
81 | pkgSendCount = 0
82 | }
83 | }
84 | } catch (e: Throwable) {
85 | error = e
86 | } finally {
87 | resultChannel.close(error) // close
88 | }
89 | }
90 |
91 | while (true) {
92 | val result = resultChannel.receiveCatching()
93 | if (result.isClosed) {
94 | result.exceptionOrNull()?.let { throw it }
95 | break // end
96 | }
97 |
98 | val data = result.getOrNull()
99 | if (data == null) {
100 | emit(Frame(null, endOfFrame = true))
101 | } else {
102 | emit(Frame(data))
103 | }
104 | }
105 | }
106 |
107 | private fun decode(source: Flow): Flow = flow {
108 | val extractor = MediaExtractor()
109 |
110 | var endOfSource = false
111 | AudioDataSource(source.onCompletion {
112 | endOfSource = true
113 | }).register(extractor, 1024)
114 |
115 | val trackIndex = getAudioTrackIndex(extractor)
116 | val format = extractor.getTrackFormat(trackIndex)
117 | val mimeType = format.getString(MediaFormat.KEY_MIME)
118 |
119 | extractor.selectTrack(trackIndex)
120 | val codec = MediaCodec.createDecoderByType(mimeType!!)
121 | codec.configure(format, null, null, 0)
122 | codec.start()
123 |
124 | while (true) {
125 | val inputIndex = codec.dequeueInputBuffer(100_000) // wait 100ms is enough.
126 | if (inputIndex >= 0) {
127 | val buffer = codec.getInputBuffer(inputIndex)
128 | when (val sampleSize = extractor.readSampleData(buffer!!, 0)) {
129 | -1 -> {
130 | if (endOfSource) {
131 | codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
132 | } else {
133 | codec.queueInputBuffer(inputIndex, 0, 0, 0, 0)
134 | }
135 | }
136 | else -> {
137 | codec.queueInputBuffer(inputIndex, 0, sampleSize, extractor.sampleTime, 0)
138 | extractor.advance()
139 | }
140 | }
141 | }
142 |
143 | val info = MediaCodec.BufferInfo()
144 | val outputIndex = codec.dequeueOutputBuffer(info, 200_000)
145 | if (outputIndex >= 0) {
146 | val output = codec.getOutputBuffer(outputIndex)
147 | if (output != null) {
148 | val dest = ByteArray(info.size)
149 | output.get(dest)
150 | emit(dest)
151 | }
152 | codec.releaseOutputBuffer(outputIndex, false)
153 | } else if (outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
154 | // wait 200ms is enough for codec to decode any pending data.
155 | if (endOfSource) {
156 | break
157 | }
158 | }
159 | }
160 |
161 | codec.stop()
162 | codec.release()
163 | extractor.release()
164 | }
165 |
166 | private fun getAudioTrackIndex(extractor: MediaExtractor): Int {
167 | for (i in 0 until extractor.trackCount) {
168 | val format = extractor.getTrackFormat(i)
169 | val mime = format.getString(MediaFormat.KEY_MIME)
170 | if (mime!!.startsWith("audio/")) {
171 | return i
172 | }
173 | }
174 | throw IllegalArgumentException("No audio track found.")
175 | }
176 |
177 | private class AudioDataSource(private val data: Flow) : MediaDataSource() {
178 | private var buffer0 = ByteBuffer2()
179 | private var dataOfEnd = false
180 |
181 | init {
182 | CoroutineScope(Dispatchers.IO).launch {
183 | data.onCompletion {
184 | dataOfEnd = true
185 | }.collect { data ->
186 | buffer0.put(data)
187 | }
188 | }
189 | }
190 |
191 |
192 | suspend fun register(extractor: MediaExtractor, loadSize: Int, errCount: Int = 0) {
193 | while (buffer0.position() < loadSize) {
194 | delay(100)
195 | }
196 | try {
197 | extractor.setDataSource(this)
198 | } catch (e: Exception) {
199 | if (errCount > 3) {
200 | throw e
201 | }
202 | Log.e("FlowDataSource", "error: ${e.message ?: "register"} - $loadSize")
203 | register(extractor, loadSize = loadSize * 2, errCount = errCount + 1)
204 | }
205 | }
206 |
207 | override fun close() {}
208 |
209 | override fun readAt(position: Long, buffer: ByteArray?, offset: Int, size: Int): Int {
210 | if (position < 0 || (dataOfEnd && position >= buffer0.position())) {
211 | return -1
212 | }
213 | val read = buffer0.copyTo(position, buffer!!, offset, size)
214 | return read
215 | }
216 |
217 | override fun getSize(): Long = -1
218 |
219 | private fun ByteBuffer.copyTo(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
220 | if (position > position()) {
221 | return 0
222 | }
223 | val start = position.toInt()
224 | val read = minOf(size - offset, position() - start)
225 | val arr = this.array()
226 | System.arraycopy(arr, start, buffer, offset, read)
227 | return read
228 | }
229 | }
230 |
231 | /**
232 | * ByteBuffer2 is better than List as backing array.
233 | * See BufferUnitTest.kt
234 | */
235 | private class ByteBuffer2 {
236 | private var cap = 1024 * 10 // 10KB
237 | private var buffer = ByteBuffer.allocate(cap)
238 |
239 | fun put(b: ByteArray) {
240 | // check grow
241 | val need = buffer.position() + b.size
242 | if (need >= buffer.capacity()) {
243 | grow(need)
244 | }
245 | buffer.put(b)
246 | }
247 |
248 | fun position() = buffer.position()
249 |
250 | fun copyTo(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
251 | if (position > position()) {
252 | return 0
253 | }
254 | val start = position.toInt()
255 | val read = minOf(size - offset, position() - start)
256 | val arr = this.buffer.array()
257 | System.arraycopy(arr, start, buffer, offset, read)
258 | return read
259 | }
260 |
261 | private fun grow(need: Int) {
262 | cap = if (cap * 2 > need) {
263 | cap * 2
264 | } else {
265 | need
266 | }
267 | val position = buffer.position()
268 | val oldArray = buffer.array()
269 | val newArray = ByteArray(cap)
270 | System.arraycopy(oldArray, 0, newArray, 0, position)
271 | buffer = ByteBuffer.wrap(newArray)
272 | buffer.position(position)
273 | }
274 | }
275 | }
276 |
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/utils/CodecTest.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.utils
2 |
3 | import android.content.Context
4 | import android.media.AudioFormat
5 | import android.media.AudioManager
6 | import android.media.AudioTrack
7 | import android.util.Log
8 | import kotlinx.coroutines.channels.Channel
9 | import kotlinx.coroutines.delay
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.flow.consumeAsFlow
12 | import kotlinx.coroutines.flow.flow
13 | import kotlinx.coroutines.flow.transform
14 | import kotlinx.coroutines.launch
15 | import kotlinx.coroutines.runBlocking
16 |
17 | class CodecTest(private val context: Context) {
18 | fun run() {
19 | runBlocking {
20 | val flow = mp3Flow().transform {
21 | if (it == null) {
22 | emit(Codec.Frame(null, true))
23 | } else {
24 | emit(Codec.Frame(it))
25 | }
26 | }
27 |
28 | var a: Channel? = null
29 | val channel = Channel>()
30 |
31 | launch {
32 | Codec(
33 | flow,
34 | context = context
35 | ).run(this.coroutineContext).collect { frame ->
36 | if (a == null) {
37 | a = Channel()
38 | channel.send(a!!)
39 | }
40 | if (frame.endOfFrame) {
41 | a!!.close()
42 | a = null
43 | } else {
44 | a!!.send(frame.data!!)
45 | }
46 | }
47 | a?.close()
48 | channel.close()
49 | }
50 |
51 | for (job in channel) {
52 | val pcmPlayer = PcmPlayer(24000, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
53 | pcmPlayer.playPcm(job.consumeAsFlow())
54 | }
55 |
56 | println()
57 | }
58 | }
59 |
60 | private fun debug(msg: String) {
61 | Log.d("EdgeTTSService CodecTest", msg)
62 | }
63 |
64 | fun mp3Flow(): Flow = flow {
65 | var count = 0
66 | val byteArray = loadFile()
67 | val step = 1024 * 2
68 | debug("file size: ${byteArray.size}")
69 | while (count < 1) {
70 | var read = 0
71 | while (true) {
72 | val len = minOf(step, byteArray.size - read)
73 | if (len <= 0) {
74 | break
75 | }
76 | emit(byteArray.copyOfRange(read, read + len))
77 | read += len
78 | debug("file remaining size: ${byteArray.size - read}")
79 | delay(100)
80 | }
81 | emit(null)
82 | count++
83 | }
84 | }
85 |
86 | private fun loadFile(): ByteArray {
87 | return ByteArray(1)
88 | // val inputStream: InputStream = context.resources.openRawResource(R.raw.text)
89 | // return inputStream.use { it.readBytes() }
90 | }
91 |
92 | class PcmPlayer(sampleRate: Int, channelConfig: Int, audioFormat: Int) {
93 | private val bufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat)
94 | private val audioTrack = AudioTrack(
95 | AudioManager.STREAM_MUSIC,
96 | sampleRate,
97 | channelConfig,
98 | audioFormat,
99 | bufferSize,
100 | AudioTrack.MODE_STREAM
101 | )
102 |
103 | fun playPcm(flow: Flow) {
104 | runBlocking {
105 | audioTrack.play()
106 | flow.collect { pcmData ->
107 | audioTrack.write(pcmData, 0, pcmData.size)
108 | }
109 | audioTrack.stop()
110 | audioTrack.release()
111 | }
112 | }
113 | }
114 | }
115 |
116 |
117 |
--------------------------------------------------------------------------------
/app/src/main/java/com/istomyang/edgetss/utils/Player.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss.utils
2 |
3 | import android.media.AudioAttributes
4 | import android.media.AudioFormat
5 | import android.media.AudioTrack
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.channels.Channel
9 | import kotlinx.coroutines.delay
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.launch
12 |
13 | class Player {
14 | class Frame(val data: ByteArray?, val endOfFrame: Boolean = false)
15 |
16 | private var player = AudioTrack.Builder()
17 | .setTransferMode(AudioTrack.MODE_STREAM)
18 | .setAudioAttributes(
19 | AudioAttributes.Builder()
20 | .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
21 | .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
22 | .build()
23 | )
24 | .setAudioFormat(
25 | AudioFormat.Builder()
26 | .setEncoding(AudioFormat.ENCODING_MP3)
27 | .setSampleRate(24000)
28 | .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
29 | .build()
30 | )
31 | .setBufferSizeInBytes(
32 | AudioTrack.getMinBufferSize(
33 | 24000,
34 | AudioFormat.CHANNEL_OUT_MONO,
35 | AudioFormat.ENCODING_MP3
36 | )
37 | )
38 | .build()
39 |
40 | private var a: Channel? = null
41 | private var jobChannel = Channel>()
42 |
43 | suspend fun run(flow: Flow , onCompleted: suspend () -> Unit) {
44 | CoroutineScope(Dispatchers.IO).launch {
45 | flow.collect { frame ->
46 | if (frame.endOfFrame) {
47 | a!!.close()
48 | a = null
49 | return@collect
50 | }
51 | if (a == null) {
52 | a = Channel()
53 | jobChannel.send(a!!)
54 | }
55 | a!!.send(frame.data!!)
56 | }
57 | jobChannel.close()
58 | }
59 |
60 | for (job in jobChannel) {
61 | for (data in job) {
62 | player.write(data, 0, data.size)
63 | }
64 | delay(1500) // sweet spot.
65 | onCompleted()
66 | }
67 | player.stop()
68 | player.release()
69 | }
70 |
71 | private var playing = false
72 |
73 | fun play() {
74 | if (playing) {
75 | return
76 | }
77 | playing = true
78 | player.play()
79 | }
80 |
81 | fun pause() {
82 | player.pause()
83 | player.flush()
84 | playing = false
85 | }
86 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
13 |
16 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/icon_launch.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/test/java/com/istomyang/edgetss/BufferUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss
2 |
3 | import org.junit.Test
4 | import java.nio.ByteBuffer
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class BufferUnitTest {
12 | @Test
13 | fun run() {
14 | val size = 1024 * 1024 * 64
15 |
16 | val t0 = System.currentTimeMillis()
17 |
18 | testByteBufferGroup(size)
19 |
20 | val t1 = System.currentTimeMillis()
21 |
22 | testByteBufferSystemCopy(size)
23 |
24 | val t2 = System.currentTimeMillis()
25 |
26 | println("test1 use: ${t1 - t0}ms")
27 | println("test2 use: ${t2 - t1}ms")
28 |
29 | // test1 use: 996ms
30 | // test2 use: 217ms
31 | }
32 |
33 | private fun testByteBufferGroup(size: Int) {
34 | val group = ByteBufferGroup()
35 | for (i in 0 until size) {
36 | group.put(i.toByte())
37 | }
38 | val dest = ByteArray(size)
39 | group.copyTo(0, dest, 0, size)
40 | println("complete: ${dest.size}")
41 | }
42 |
43 | private fun testByteBufferSystemCopy(size: Int) {
44 | val group = ByteBufferSystemCopy()
45 | for (i in 0 until size) {
46 | group.put(i.toByte())
47 | }
48 | val dest = ByteArray(size)
49 | group.copyTo(0, dest, 0, size)
50 | println("complete: ${dest.size}")
51 | }
52 | }
53 |
54 |
55 | private class ByteBufferGroup() {
56 | private val unitSize = 16
57 | private val buffers = mutableListOf()
58 |
59 | var maxPosition = 0L
60 |
61 | init {
62 | grow()
63 | }
64 |
65 | fun put(b: Byte) {
66 | var buffer = buffers.last()
67 | if (buffer.position() >= buffer.capacity()) {
68 | grow()
69 | buffer = buffers.last()
70 | }
71 | buffer.put(b)
72 | maxPosition += 1
73 | }
74 |
75 | fun copyTo(position: Long, buffer: ByteArray?, offset: Int, size: Int): Int {
76 | val start = position
77 | val end = minOf(position + size, maxPosition)
78 | for (i in start until end) {
79 | val b = get(i)
80 | val idx = (i - start + offset).toInt()
81 | buffer?.set(idx, b)
82 | }
83 | return (end - start).toInt()
84 | }
85 |
86 | private fun get(position: Long): Byte {
87 | val group = position / unitSize
88 | val buffer = buffers[group.toInt()]
89 | val idx = position % unitSize
90 | return buffer.get(idx.toInt())
91 | }
92 |
93 | private fun grow() {
94 | val buffer = ByteBuffer.allocate(unitSize)
95 | buffers.add(buffer)
96 | }
97 | }
98 |
99 |
100 | private class ByteBufferSystemCopy() {
101 | private var cap = 16
102 | private var buffer = ByteBuffer.allocate(cap)
103 |
104 | var maxPosition = 0
105 |
106 | fun put(b: Byte) {
107 | if (buffer.position() >= buffer.capacity()) {
108 | grow()
109 | }
110 | buffer.put(b)
111 | maxPosition += 1
112 | }
113 |
114 | fun copyTo(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
115 | val start = position.toInt()
116 | val read = minOf(size, maxPosition - start)
117 | val arr = this.buffer.array()
118 | System.arraycopy(arr, start, buffer, offset, read)
119 | return read
120 | }
121 |
122 | private fun grow() {
123 | cap = cap * 2
124 | val position = buffer.position()
125 | val oldArray = buffer.array()
126 | val newArray = ByteArray(cap)
127 | System.arraycopy(oldArray, 0, newArray, 0, position)
128 | buffer = ByteBuffer.wrap(newArray)
129 | buffer.position(position)
130 | }
131 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/istomyang/edgetss/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss
2 |
3 | import kotlinx.coroutines.channels.Channel
4 | import kotlinx.coroutines.delay
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.flow.flow
7 | import kotlinx.coroutines.flow.onCompletion
8 | import kotlinx.coroutines.flow.onEach
9 | import kotlinx.coroutines.flow.onStart
10 | import kotlinx.coroutines.launch
11 | import kotlinx.coroutines.runBlocking
12 | import org.junit.Test
13 | import java.nio.ByteBuffer
14 |
15 | /**
16 | * Example local unit test, which will execute on the development machine (host).
17 | *
18 | * See [testing documentation](http://d.android.com/tools/testing).
19 | */
20 | class ExampleUnitTest {
21 | @Test
22 | fun bytes() {
23 | val a = (5 / 2).toInt()
24 | println(a)
25 | }
26 |
27 | @Test
28 | fun channel() {
29 | val ch = Channel(32)
30 | runBlocking {
31 | ch.send(1)
32 | ch.send(2)
33 | ch.send(3)
34 | ch.send(4)
35 | ch.send(5)
36 | ch.send(6)
37 | ch.close()
38 | }
39 | runBlocking {
40 | var count = 0
41 | for (i in ch) {
42 | count += 1
43 | println(i)
44 | }
45 | println(count)
46 | for (i in ch) {
47 | count += 1
48 | println(i)
49 | }
50 | println(count)
51 | }
52 | }
53 |
54 | @Test
55 | fun test_flow() {
56 | runBlocking {
57 | val f = emit().onStart {
58 | println("start")
59 | }.onEach {
60 | println("$it")
61 | }.onCompletion {
62 | println("completed")
63 | }
64 |
65 | squared(f).onStart {
66 | println("start2")
67 | }.onEach {
68 | println("$it")
69 | }.onCompletion {
70 | println("completed2")
71 | }.collect { it ->
72 | println(it)
73 | }
74 | }
75 | }
76 |
77 | @Test
78 | fun test_flow2() {
79 | runBlocking {
80 | emit().onStart {
81 | println("start")
82 | }.onEach {
83 | println("$it")
84 | }.onCompletion {
85 | println("completed")
86 | }.squared.onStart {
87 | println("start2")
88 | }.onEach {
89 | println("$it")
90 | }.onCompletion {
91 | println("completed2")
92 | }.collect { it ->
93 | println(it)
94 | }
95 | }
96 | }
97 |
98 | private fun emit() = flow {
99 | delay(3000)
100 | for (i in 1..10) {
101 | delay(100)
102 | emit(i)
103 | }
104 | }
105 |
106 | val Flow.squared get() = squared(this)
107 |
108 | private fun squared(src: Flow) = flow {
109 | src.collect {
110 | emit(it * 2)
111 | }
112 | }
113 |
114 | @Test
115 | fun example2() {
116 | runBlocking {
117 | val buffer = ByteBuffer.allocate(100)
118 |
119 | launch {
120 | for (i in 1..10) {
121 | delay(100)
122 | buffer.put(i.toByte())
123 | println(i)
124 | }
125 | }
126 |
127 | while (true) {
128 | if (buffer.position() != buffer.limit()) {
129 | delay(30)
130 | continue
131 | }
132 | println("ok")
133 | }
134 | }
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/app/src/test/java/com/istomyang/edgetss/FlowUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.edgetss
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.channels.Channel
6 | import kotlinx.coroutines.delay
7 | import kotlinx.coroutines.flow.flow
8 | import kotlinx.coroutines.flow.onCompletion
9 | import kotlinx.coroutines.flow.onEmpty
10 | import kotlinx.coroutines.launch
11 | import kotlinx.coroutines.runBlocking
12 | import org.junit.Test
13 | import kotlin.coroutines.CoroutineContext
14 |
15 | /**
16 | * Example local unit test, which will execute on the development machine (host).
17 | *
18 | * See [testing documentation](http://d.android.com/tools/testing).
19 | */
20 | class FlowUnitTest {
21 | @Test
22 | fun exampleChannel() {
23 | runBlocking {
24 | try {
25 | flow1(coroutineContext + Dispatchers.IO).onCompletion {
26 | println("onCompletion")
27 | }.onEmpty {
28 | println("onEmpty")
29 | }.collect {
30 | println(it)
31 | }
32 | } catch (e: Throwable) {
33 | println("@error: ${e.message}")
34 | }
35 | }
36 | }
37 |
38 | private fun flow1(context: CoroutineContext) = flow {
39 | val dataChannel = Channel()
40 | val resultChannel = Channel()
41 |
42 | CoroutineScope(context).launch {
43 | repeat(5) {
44 | dataChannel.send(it)
45 | }
46 | dataChannel.close()
47 | }
48 |
49 | CoroutineScope(context).launch {
50 | var error: Throwable? = null
51 | try {
52 | for (num in dataChannel) {
53 | delay(500)
54 | resultChannel.send(num)
55 | }
56 | throw Exception("error")
57 | } catch (e: Throwable) {
58 | error = e
59 | } finally {
60 | resultChannel.close(error)
61 | }
62 | }
63 |
64 | while (true) {
65 | val result = resultChannel.receiveCatching()
66 | when {
67 | result.isSuccess -> {
68 | emit(result.getOrThrow())
69 | }
70 |
71 | result.isClosed -> {
72 | result.exceptionOrNull()?.let {
73 | throw it
74 | }
75 | break
76 | }
77 | }
78 | }
79 |
80 | println("flow completed")
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | kotlin("jvm") version "2.0.21"
4 | alias(libs.plugins.compose.compiler) apply false
5 | alias(libs.plugins.android.application) apply false
6 | alias(libs.plugins.kotlin.android) apply false
7 | id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
8 | alias(libs.plugins.android.library) apply false
9 | kotlin("plugin.serialization") version "2.1.0"
10 | }
11 |
12 | java {
13 | sourceCompatibility = JavaVersion.VERSION_17
14 | targetCompatibility = JavaVersion.VERSION_17
15 | }
16 |
--------------------------------------------------------------------------------
/docs/images/Screenshot_2024-12-25-14-59-03-429_com.istomyang.edgetss.release.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/docs/images/Screenshot_2024-12-25-14-59-03-429_com.istomyang.edgetss.release.jpg
--------------------------------------------------------------------------------
/docs/images/Screenshot_2024-12-25-14-59-36-340_com.istomyang.edgetss.release.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/docs/images/Screenshot_2024-12-25-14-59-36-340_com.istomyang.edgetss.release.jpg
--------------------------------------------------------------------------------
/engine/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/engine/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("java-library")
3 | alias(libs.plugins.jetbrains.kotlin.jvm)
4 | kotlin("plugin.serialization") version "2.1.0"
5 | }
6 |
7 | java {
8 | sourceCompatibility = JavaVersion.VERSION_17
9 | targetCompatibility = JavaVersion.VERSION_17
10 | }
11 |
12 | kotlin {
13 | compilerOptions {
14 | jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
15 | }
16 | }
17 |
18 | dependencies {
19 | testImplementation(libs.junit.junit)
20 | val ktorVersion = "3.0.2"
21 | implementation("io.ktor:ktor-client-core:$ktorVersion")
22 | implementation("io.ktor:ktor-client-cio:$ktorVersion")
23 | implementation("io.ktor:ktor-client-websockets:$ktorVersion")
24 |
25 | implementation(libs.jetbrains.kotlinx.serialization.json)
26 | }
--------------------------------------------------------------------------------
/engine/src/main/java/com/istomyang/tts_engine/DRM.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.tts_engine
2 |
3 | import java.math.BigInteger
4 | import java.security.MessageDigest
5 | import java.text.ParseException
6 | import java.text.SimpleDateFormat
7 | import java.util.Date
8 | import java.util.Locale
9 | import java.util.TimeZone
10 |
11 | internal class DRM {
12 | companion object {
13 | private var clockSkewSeconds: Double = 0.0
14 |
15 | fun genSecMsGec(): String {
16 | // ref: https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570
17 | var t = getUnixTSSec()
18 | t += 11644473600
19 | t -= (t % 300)
20 | t *= 1e9 / 100
21 | val s = "%d6A5AA1D4EAFF4E9FB37E23D68491D6F4".format(t.toLong()).toByteArray(Charsets.US_ASCII)
22 | val digest = MessageDigest.getInstance("SHA-256").digest(s)
23 | return BigInteger(1, digest).toString(16).uppercase()
24 | }
25 |
26 | fun adjustClockSkew(rfc2616Time: String) {
27 | val clientTime = getUnixTSSec()
28 | val serverTime = parseRfc2616Date(rfc2616Time)
29 | if (serverTime != null) {
30 | clockSkewSeconds = serverTime - clientTime
31 | }
32 | }
33 |
34 | private fun getUnixTSSec(): Double {
35 | return System.currentTimeMillis().toDouble() / 1000 + clockSkewSeconds
36 | }
37 |
38 | private fun parseRfc2616Date(rfc2616Time: String): Double? {
39 | val rfc2616Format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US)
40 | rfc2616Format.timeZone = TimeZone.getTimeZone("GMT")
41 | try {
42 | val date: Date = rfc2616Format.parse(rfc2616Time)
43 | return date.time.toDouble() / 1000
44 | } catch (e: ParseException) {
45 | e.printStackTrace()
46 | return null
47 | }
48 | }
49 | }
50 | }
--------------------------------------------------------------------------------
/engine/src/main/java/com/istomyang/tts_engine/SpeakerManager.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.tts_engine
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.cio.CIO
5 | import io.ktor.client.request.get
6 | import io.ktor.client.request.header
7 | import io.ktor.client.statement.bodyAsText
8 | import io.ktor.http.isSuccess
9 | import kotlinx.serialization.SerialName
10 | import kotlinx.serialization.Serializable
11 | import kotlinx.serialization.json.Json
12 |
13 | class SpeakerManager {
14 | suspend fun list(): Result> {
15 | val client = HttpClient(CIO) {}
16 |
17 | val res = client.get(urlString = buildUrl()) {
18 | header("sec-ch-ua-platform", "macOS")
19 | header(
20 | "User-Agent",
21 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
22 | )
23 | header(
24 | "sec-ch-ua",
25 | "\"Microsoft Edge\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\""
26 | )
27 | header("sec-ch-ua-mobile", "?0")
28 | header("Accept", "*/*")
29 | header("X-Edge-Shopping-Flag", "1")
30 | header("Sec-MS-GEC", DRM.genSecMsGec())
31 | header("Sec-MS-GEC-Version", "1-131.0.2903.70")
32 | header("Sec-Fetch-Site", "none")
33 | header("Sec-Fetch-Mode", "cors")
34 | header("Sec-Fetch-Dest", "empty")
35 | header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6")
36 | header("Accept-Encoding", "gzip, deflate, br, zstd")
37 | }
38 |
39 | if (!res.status.isSuccess()) {
40 | return Result.failure(Throwable(res.status.description))
41 | }
42 |
43 | client.close()
44 |
45 | val obj = Json.decodeFromString>(res.bodyAsText())
46 | return Result.success(obj)
47 | }
48 |
49 | private fun buildUrl(): String {
50 | return "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&Sec-MS-GEC=${DRM.genSecMsGec()}&Sec-MS-GEC-Version=1-131.0.2903.70"
51 | }
52 |
53 | // {
54 | // "Name": "Microsoft Server Speech Text to Speech Voice (en-US, AvaMultilingualNeural)",
55 | // "ShortName": "en-US-AvaMultilingualNeural",
56 | // "Gender": "Female",
57 | // "Locale": "en-US",
58 | // "SuggestedCodec": "audio-24khz-48kbitrate-mono-mp3",
59 | // "FriendlyName": "Microsoft AvaMultilingual Online (Natural) - English (United States)",
60 | // "Status": "GA",
61 | // "VoiceTag": {
62 | // "ContentCategories": ["Conversation", "Copilot"],
63 | // "VoicePersonalities": ["Expressive", "Caring", "Pleasant", "Friendly"]
64 | // }
65 | //}
66 |
67 |
68 | @Serializable
69 | data class SpeakerInfo(
70 | @SerialName("Name")
71 | val name: String,
72 | @SerialName("ShortName")
73 | val shortName: String,
74 | @SerialName("Gender")
75 | val gender: String,
76 | @SerialName("Locale")
77 | val locale: String,
78 | @SerialName("SuggestedCodec")
79 | val suggestedCodec: String,
80 | @SerialName("FriendlyName")
81 | val friendlyName: String,
82 | @SerialName("Status")
83 | val status: String,
84 | @SerialName("VoiceTag")
85 | val voiceTag: Tag
86 | )
87 |
88 | @Serializable
89 | data class Tag(
90 | @SerialName("ContentCategories")
91 | val contentCategories: List,
92 | @SerialName("VoicePersonalities")
93 | val voicePersonalities: List
94 | )
95 |
96 | enum class OutputFormat(val value: String) {
97 | Webm24Khz16BitMonoOpus("webm-24khz-16bit-mono-opus"),
98 | Audio24Khz48KbitrateMonoMp3("audio-24khz-48kbitrate-mono-mp3"),
99 | Audio24Khz96KbitrateMonoMp3("audio-24khz-96kbitrate-mono-mp3"),
100 | }
101 | }
--------------------------------------------------------------------------------
/engine/src/main/java/com/istomyang/tts_engine/TTS.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.tts_engine
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.cio.CIO
5 | import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
6 | import io.ktor.client.plugins.websocket.WebSockets
7 | import io.ktor.client.plugins.websocket.webSocketSession
8 | import io.ktor.client.request.HttpRequestBuilder
9 | import io.ktor.client.request.header
10 | import io.ktor.client.request.request
11 | import io.ktor.client.request.url
12 | import io.ktor.util.decodeString
13 | import io.ktor.websocket.Frame
14 | import io.ktor.websocket.close
15 | import io.ktor.websocket.readBytes
16 | import io.ktor.websocket.readReason
17 | import io.ktor.websocket.readText
18 | import io.ktor.websocket.send
19 | import kotlinx.coroutines.CancellationException
20 | import kotlinx.coroutines.channels.Channel
21 | import kotlinx.coroutines.delay
22 | import kotlinx.coroutines.flow.Flow
23 | import kotlinx.coroutines.flow.consumeAsFlow
24 | import java.nio.ByteBuffer
25 | import java.time.ZoneOffset
26 | import java.time.ZonedDateTime
27 | import java.time.format.DateTimeFormatter
28 | import java.util.Locale
29 |
30 | class TTS {
31 | private val resultChannel = Channel(8)
32 | private val chunkChannel = Channel(8)
33 |
34 | // Can reuse ws connection.
35 | private var client: HttpClient? = null
36 | private var session: DefaultClientWebSocketSession? = null
37 |
38 | /**
39 | * At first, I used Frame to save metadata and data, then I discovered that input must wait previous output done.
40 | */
41 | private var currentMetadata: AudioMetaData? = null
42 |
43 | private var errCount = 0
44 |
45 | suspend fun run() {
46 | try {
47 | client = HttpClient(CIO) {
48 | install(WebSockets) {
49 | pingIntervalMillis = 20_000L
50 | }
51 | }
52 |
53 | session = client!!.webSocketSession {
54 | makeHttpRequestBuilder(this)
55 | }
56 |
57 | for (chunk in chunkChannel) {
58 | if (chunk == null) {
59 | resultChannel.send(AudioFrame(null, textCompleted = true))
60 | continue
61 | }
62 | val md = currentMetadata!!
63 | val ssml = buildSSML(chunk, metadata = md)
64 | val speech = buildSpeechConfig(md.outputFormat)
65 | communicate(speech, ssml) {
66 | if (it == null) {
67 | resultChannel.send(AudioFrame(null, audioCompleted = true))
68 | return@communicate
69 | }
70 | resultChannel.send(AudioFrame(it))
71 | }
72 | }
73 | } catch (_: CancellationException) {
74 | } catch (e: Throwable) {
75 | if (e.message != null && e.message!!.contains("403") && errCount < 3) {
76 | delay(500L)
77 | errCount += 1
78 | resolveHttp403()
79 | run()
80 | return
81 | }
82 | throw e
83 | }
84 | }
85 |
86 | suspend fun close() {
87 | session?.close()
88 | client?.close()
89 | }
90 |
91 | suspend fun input(text: String, metadata: AudioMetaData) {
92 | if (metadata.invalid()) {
93 | throw Exception("Voice is invalid.")
94 | }
95 | if (text.invalid()) {
96 | throw Exception("Text is invalid.")
97 | }
98 | currentMetadata = metadata
99 |
100 | val chunkSize = estimateTextLength(metadata)
101 | for (chunk in text.removeEmojis().trim().escapeXml().intoChunks(chunkSize)) {
102 | chunkChannel.send(chunk)
103 | }
104 | chunkChannel.send(null)
105 | }
106 |
107 | /**
108 | * Output audio data maybe has a every long text input and output more than one audio file data.
109 | * You should compare Audio Metadata in order to group audios by text.
110 | *
111 | * Null represents the end of the audio file.
112 | */
113 | fun output(): Flow = resultChannel.consumeAsFlow()
114 |
115 | private suspend fun resolveHttp403() {
116 | val builder = makeHttpRequestBuilder(HttpRequestBuilder(), useWs = false)
117 | val res = client!!.request(builder)
118 | val date = res.headers["Date"]
119 | if (date != null) {
120 | DRM.adjustClockSkew(date)
121 | }
122 | }
123 |
124 | private fun makeHttpRequestBuilder(builder: HttpRequestBuilder, useWs: Boolean = true): HttpRequestBuilder {
125 | return builder.apply {
126 | url(buildUrl(useWs))
127 | header("Pragma", "no-cache")
128 | header("Cache-Control", "no-cache")
129 | header(
130 | "User-Agent",
131 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
132 | )
133 | header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold")
134 | header("Accept-Encoding", "gzip, deflate, br")
135 | header("Accept-Language", "en-US,en;q=0.9")
136 | }
137 | }
138 |
139 | private suspend fun communicate(speech: String, ssml: String, onReceived: suspend (ByteArray?) -> Unit) {
140 | val session = session!!
141 |
142 | session.send(speech)
143 | session.send(ssml)
144 |
145 | while (true) {
146 | when (val message = session.incoming.receive()) {
147 | is Frame.Text -> {
148 | val data = message.readText()
149 | if (data.contains("Path:turn.end")) {
150 | onReceived(null)
151 | return
152 | }
153 | }
154 |
155 | is Frame.Binary -> {
156 | val data = message.readBytes()
157 |
158 | // Because AI generates Audio Token continuously,
159 | // the client needs to accept a block of data continuously.
160 | // metadata data length is 2.
161 | val audio = data.sliceArray(2 until data.size)
162 | onReceived(audio)
163 | }
164 |
165 | is Frame.Close -> {
166 | throw Exception("WebSocket Close: ${message.readReason()?.message}")
167 | }
168 |
169 | else -> {
170 | throw Exception("Unexpected behavior: $message")
171 | }
172 | }
173 | }
174 | }
175 |
176 | private fun buildSpeechConfig(outputFormat: String): String {
177 | val timestamp = datetime2String()
178 | val contentType = "application/json; charset=utf-8"
179 | val path = "speech.config"
180 | return """
181 | X-Timestamp:$timestamp
182 | Content-Type:$contentType
183 | Path:$path
184 |
185 | {"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"false"},"outputFormat":"$outputFormat"}}}}
186 | """.trimIndent().replace("\n", "\r\n")
187 | }
188 |
189 | private fun buildUrl(useWs: Boolean): String {
190 | return String.format(
191 | "%s://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&Sec-MS-GEC=%s&Sec-MS-GEC-Version=1-131.0.2903.51&ConnectionId=%s",
192 | if (useWs) "wss" else "https",
193 | DRM.genSecMsGec(),
194 | newUUID()
195 | )
196 | }
197 |
198 | private fun estimateTextLength(metadata: AudioMetaData): Int {
199 | val lang = metadata.locale
200 | val voice = metadata.voiceName
201 | val pitch = metadata.pitch
202 | val rate = metadata.rate
203 | val volume = metadata.volume
204 | val tplSize = """
205 | X-RequestId:84dcbd2ef9591c28db9435451ea447c0
206 | Content-Type:application/ssml+xml
207 | X-Timestamp:Fri Jan 17 2025 13:22:44 GMT+0000 (Coordinated Universal Time)
208 | Path:ssml
209 |
210 |
211 | """.trimIndent().replace("\n", "\r\n").toByteArray(Charsets.UTF_8).size
212 | val wsMsgSize = 2 shl 16
213 | return wsMsgSize - tplSize - 50 // 50 is safe margin of error
214 | }
215 |
216 | private fun buildSSML(
217 | text: String,
218 | metadata: AudioMetaData
219 | ): String {
220 | val lang = metadata.locale
221 | val voice = metadata.voiceName
222 | val pitch = metadata.pitch
223 | val rate = metadata.rate
224 | val volume = metadata.volume
225 | return """
226 | X-RequestId:${newUUID()}
227 | Content-Type:application/ssml+xml
228 | X-Timestamp:${datetime2String()}
229 | Path:ssml
230 |
231 | $text
232 | """.trimIndent().replace("\n", "\r\n")
233 | }
234 |
235 | class AudioFrame(
236 | val data: ByteArray?,
237 | val textCompleted: Boolean = false,
238 | val audioCompleted: Boolean = false
239 | )
240 |
241 | data class AudioMetaData(
242 | val locale: String,
243 | val voiceName: String,
244 | val volume: String,
245 | val outputFormat: String,
246 | val pitch: String,
247 | val rate: String,
248 | ) {
249 | internal fun invalid(): Boolean {
250 | return locale.isEmpty() || voiceName.isEmpty() || volume.isEmpty() || outputFormat.isEmpty() || pitch.isEmpty() || rate.isEmpty()
251 | }
252 | }
253 |
254 | private fun datetime2String(): String {
255 | val now = ZonedDateTime.now(ZoneOffset.UTC)
256 | val format =
257 | DateTimeFormatter.ofPattern(
258 | "EEE MMM dd yyyy HH:mm:ss 'GMT+0000' '(Coordinated Universal Time)'",
259 | Locale.ENGLISH
260 | )
261 | return now.format(format)
262 | }
263 |
264 |
265 | private fun String.escapeXml(): String {
266 | return this.replace("&", "&")
267 | .replace("<", "<")
268 | .replace(">", ">")
269 | .replace("\"", """)
270 | .replace("'", "'")
271 | }
272 |
273 | private fun String.removeEmojis(): String {
274 | return this.replace("[\\uD83C-\\uDBFF\\uDC00-\\uDFFF]+".toRegex(), "")
275 | .replace("[\\u2600-\\u27BF]+".toRegex(), "")
276 | .replace("[\\uD83D-\\uDDFF]+".toRegex(), "")
277 | .replace("[\\uD83E-\\uDDFF]+".toRegex(), "")
278 | }
279 |
280 | private fun String.intoChunks(chunkSize: Int): List {
281 | val chunks = mutableListOf()
282 | val buffer = ByteBuffer.allocate(chunkSize)
283 | for (char in this) {
284 | val bytes = char.toString().toByteArray(Charsets.UTF_8)
285 | if (buffer.remaining() < bytes.size) {
286 | buffer.flip()
287 | chunks.add(buffer.decodeString(Charsets.UTF_8))
288 | buffer.clear()
289 | }
290 | buffer.put(bytes)
291 | }
292 | buffer.flip()
293 | chunks.add(buffer.decodeString(Charsets.UTF_8))
294 | return chunks
295 | }
296 |
297 | private fun String.invalid(): Boolean {
298 | // todo:
299 | return this.removeEmojis().trim().isEmpty()
300 | }
301 |
302 | private fun newUUID(): String {
303 | return java.util.UUID.randomUUID().toString().replace("-", "")
304 | }
305 | }
--------------------------------------------------------------------------------
/engine/src/test/java/com/istomyang/tts_engine/ChannelUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.tts_engine
2 |
3 | import kotlinx.coroutines.channels.Channel
4 | import kotlinx.coroutines.launch
5 | import kotlinx.coroutines.runBlocking
6 | import org.junit.Test
7 |
8 | class ChannelUnitTest {
9 | @Test
10 | fun example() {
11 | runBlocking {
12 | val channel = Channel(8)
13 | val close = Channel()
14 |
15 | launch {
16 | repeat(8) {
17 | channel.send(it)
18 | }
19 | channel.close(Exception("123"))
20 | close.send(Unit)
21 | }
22 |
23 | close.receive()
24 |
25 | try {
26 | while (true) {
27 | val a = channel.receiveCatching()
28 | if (a.isClosed) {
29 | a.exceptionOrNull()?.let {
30 | throw it
31 | }
32 | break
33 | }
34 | }
35 | } catch (e: Exception) {
36 | e.printStackTrace()
37 | }
38 |
39 | println("Done")
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/engine/src/test/java/com/istomyang/tts_engine/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.tts_engine
2 |
3 | import kotlinx.coroutines.delay
4 | import kotlinx.coroutines.launch
5 | import kotlinx.coroutines.runBlocking
6 | import org.junit.Test
7 | import java.io.ByteArrayOutputStream
8 | import java.io.FileOutputStream
9 | import java.time.ZoneOffset
10 | import java.time.ZonedDateTime
11 | import java.time.format.DateTimeFormatter
12 | import java.util.Locale
13 |
14 | /**
15 | * Example local unit test, which will execute on the development machine (host).
16 | *
17 | * See [testing documentation](http://d.android.com/tools/testing).
18 | */
19 | class ExampleUnitTest {
20 | @Test
21 | fun example() {
22 | runBlocking {
23 | SpeakerManager().list().onSuccess {
24 | println(it)
25 | }.onFailure {
26 | it.printStackTrace()
27 | }
28 | }
29 | }
30 |
31 | @Test
32 | fun runTTS() {
33 | runBlocking {
34 | val engine = TTS()
35 |
36 | val metadata = TTS.AudioMetaData(
37 | locale = "en-US",
38 | voiceName = "Microsoft Server Speech Text to Speech Voice (en-US, JennyNeural)",
39 | volume = "+0%",
40 | outputFormat = "audio-24khz-48kbitrate-mono-mp3",
41 | pitch = "+0Hz",
42 | rate = "+25%",
43 | )
44 |
45 | launch {
46 | try {
47 | engine.run()
48 | } catch (e: Exception) {
49 | e.printStackTrace()
50 | }
51 | }
52 |
53 | launch {
54 | val stream = ByteArrayOutputStream()
55 | var count = 1
56 | engine.output().collect {
57 | if (it.audioCompleted) {
58 | val name = "test$count.mp3"
59 | writeFile(name, stream.toByteArray())
60 | println("write to file: $name")
61 | count++
62 | stream.reset()
63 | return@collect
64 | }
65 | if (it.textCompleted) {
66 | println("text is ok.")
67 | return@collect
68 | }
69 | stream.write(it.data)
70 | println("write to stream: ${it.data!!.size}")
71 | }
72 | }
73 |
74 | inputText(engine, metadata)
75 | // inputTextDelay(engine, metadata)
76 | // inputLongText(engine, metadata)
77 |
78 | delay(10_000)
79 | engine.close()
80 | println("close engine")
81 | }
82 | }
83 |
84 | @Test
85 | fun example2() {
86 | val s = datetime2String()
87 | println(s)
88 | }
89 |
90 | private suspend fun inputText(engine: TTS, md: TTS.AudioMetaData) {
91 | val txt = "This was a big problem for candidate Trump. It was also a big problem for me."
92 | engine.input("1$txt", md)
93 | }
94 |
95 | private suspend fun inputTextDelay(engine: TTS, md: TTS.AudioMetaData) {
96 | val txt = "This was a big problem for candidate Trump. It was also a big problem for me."
97 | delay(10_000)
98 | engine.input("1$txt", md)
99 | delay(3_000)
100 | engine.input("2$txt", md)
101 | }
102 |
103 | private suspend fun inputLongText(engine: TTS, md: TTS.AudioMetaData) {
104 | val txt = "This was a big problem for candidate Trump. It was also a big problem for me."
105 | val builder = StringBuilder()
106 | for (i in 1..1000) {
107 | builder.append("$i$txt")
108 | }
109 | engine.input(builder.toString(), md)
110 | }
111 |
112 | private fun datetime2String(): String {
113 | val now = ZonedDateTime.now(ZoneOffset.UTC)
114 | val format =
115 | DateTimeFormatter.ofPattern(
116 | "EEE MMM dd yyyy HH:mm:ss 'GMT+0000' '(Coordinated Universal Time)'",
117 | Locale.ENGLISH
118 | )
119 | return now.format(format)
120 | }
121 |
122 | private fun writeFile(name: String, data: ByteArray) {
123 | FileOutputStream(name).use { fos ->
124 | fos.write(data)
125 | }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/engine/src/test/java/com/istomyang/tts_engine/SomeUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.istomyang.tts_engine
2 |
3 | import io.ktor.util.decodeString
4 | import io.ktor.util.moveToByteArray
5 | import kotlinx.coroutines.joinAll
6 | import kotlinx.coroutines.launch
7 | import kotlinx.coroutines.runBlocking
8 | import kotlinx.coroutines.supervisorScope
9 | import org.junit.Test
10 | import java.nio.ByteBuffer
11 |
12 | class SomeUnitTest {
13 | @Test
14 | fun example() {
15 | runBlocking {
16 | println("hello")
17 | supervisorScope {
18 | val job1 = launch {
19 | println("@@@ test1")
20 | throw Exception("test1")
21 | }
22 | val job2 = launch {
23 | println("@@@ test2")
24 | throw Exception("test2")
25 | }
26 | val job3 = launch {
27 | println("@@@ test3")
28 | throw Exception("test3")
29 | }
30 |
31 | try {
32 | joinAll(job1, job2, job3)
33 | } catch (e: Exception) {
34 | println("error1: ${e.message}")
35 | }
36 | }
37 | }
38 | }
39 |
40 | @Test
41 | fun example2() {
42 | val text = "123abc.。你好👋".removeEmojis() // 11 char
43 | val chunkSize = 10
44 | val chunks = mutableListOf()
45 |
46 | val buffer = ByteBuffer.allocate(chunkSize)
47 | for (char in text) {
48 | val bytes = char.toString().toByteArray(Charsets.UTF_8)
49 | if (buffer.remaining() < bytes.size) {
50 | buffer.flip()
51 | chunks.add(buffer.decodeString(Charsets.UTF_8))
52 | buffer.clear()
53 | }
54 | buffer.put(bytes)
55 | }
56 | buffer.flip()
57 | chunks.add(buffer.decodeString(Charsets.UTF_8))
58 |
59 | println(chunks)
60 | }
61 |
62 | private fun String.removeEmojis(): String {
63 | return this.replace("[\\uD83C-\\uDBFF\\uDC00-\\uDFFF]+".toRegex(), "")
64 | .replace("[\\u2600-\\u27BF]+".toRegex(), "")
65 | .replace("[\\uD83D-\\uDDFF]+".toRegex(), "")
66 | .replace("[\\uD83E-\\uDDFF]+".toRegex(), "")
67 | }
68 |
69 | @Test
70 | fun example3() {
71 | val buffer = ByteBuffer.allocate(10)
72 | buffer.put(0)
73 | println(buffer.position())
74 | println(buffer.remaining())
75 | println(buffer.limit())
76 |
77 | buffer.flip()
78 | val a = buffer.moveToByteArray()
79 | println(a)
80 | println(buffer.position())
81 | }
82 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | android.enableR8.fullDebugLogging=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.7.2"
3 | datastorePreferences = "1.1.1"
4 | junitJupiterApi = "5.7.0"
5 | junitJupiterEngine = "5.7.0"
6 | kotlin = "2.0.21"
7 | coreKtx = "1.10.1"
8 | junit = "4.13.2"
9 | junitVersion = "1.1.5"
10 | espressoCore = "3.5.1"
11 | kotlinxSerializationJsonVersion = "1.8.0"
12 | ktorVersion = "3.0.3"
13 | lifecycleRuntimeKtx = "2.6.1"
14 | activityCompose = "1.8.0"
15 | composeBom = "2024.04.01"
16 | lifecycleViewmodelCompose = "2.8.5"
17 | navigationComposeVersion = "2.8.4"
18 | preferenceVersion = "1.2.1"
19 | media3Exoplayer = "1.5.0"
20 | roomCompiler = "2.5.1"
21 | roomVersion = "2.6.1"
22 | datastoreCoreAndroid = "1.1.1"
23 | datastoreCoreJvm = "1.1.1"
24 | constraintlayout = "2.2.0"
25 | lifecycleRuntimeComposeAndroid = "2.8.7"
26 | appcompat = "1.6.1"
27 | material = "1.10.0"
28 | jetbrainsKotlinJvm = "2.0.21"
29 | testng = "6.9.6"
30 | junitJunit = "4.13.2"
31 |
32 | [libraries]
33 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.12.0" }
34 | androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
35 | androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
36 | androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceVersion" }
37 | androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
38 | androidx-room-room-runtime2 = { module = "androidx.room:room-runtime", version.ref = "roomVersion" }
39 | androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" }
40 | androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomVersion" }
41 | jetbrains-kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJsonVersion" }
42 | junit = { group = "junit", name = "junit", version.ref = "junit" }
43 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
44 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
45 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
46 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
47 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
48 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
49 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
50 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
51 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
52 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version = "1.6.6" }
53 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
54 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
55 | androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3Exoplayer" }
56 | androidx-datastore-core-android = { group = "androidx.datastore", name = "datastore-core-android", version.ref = "datastoreCoreAndroid" }
57 | androidx-datastore-core-jvm = { group = "androidx.datastore", name = "datastore-core-jvm", version.ref = "datastoreCoreJvm" }
58 | junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiterEngine" }
59 | junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiterApi" }
60 | kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" }
61 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" }
62 | ktor-serialization-gson = { module = "io.ktor:ktor-serialization-gson", version.ref = "ktorVersion" }
63 | material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
64 | material3 = { module = "androidx.compose.material3:material3" }
65 | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
66 | navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationComposeVersion" }
67 | androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "lifecycleRuntimeComposeAndroid" }
68 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
69 | material = { group = "com.google.android.material", name = "material", version.ref = "material" }
70 | testng = { group = "org.testng", name = "testng", version.ref = "testng" }
71 | junit-junit = { group = "junit", name = "junit", version.ref = "junitJunit" }
72 |
73 | [plugins]
74 | android-application = { id = "com.android.application", version.ref = "agp" }
75 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
76 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
77 | android-library = { id = "com.android.library", version.ref = "agp" }
78 | jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }
79 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yyangv/edge-tts-android/75eccb225c70ac66c38aa8370e7a489fa69b1374/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Wed Dec 25 14:05:51 CST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11-bin.zip
5 | # distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-bin.zip
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | maven {
4 | url = uri("https://maven.aliyun.com/repository/public/")
5 | }
6 | maven {
7 | url = uri("https://maven.aliyun.com/repository/central")
8 | }
9 | google {
10 | content {
11 | includeGroupByRegex("com\\.android.*")
12 | includeGroupByRegex("com\\.google.*")
13 | includeGroupByRegex("androidx.*")
14 | }
15 | }
16 | mavenCentral()
17 | gradlePluginPortal()
18 | }
19 | }
20 | dependencyResolutionManagement {
21 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
22 | repositories {
23 | maven {
24 | url = uri("https://maven.aliyun.com/repository/public/")
25 | }
26 | maven {
27 | url = uri("https://maven.aliyun.com/repository/central")
28 | }
29 | google()
30 | mavenCentral()
31 | }
32 | }
33 |
34 | rootProject.name = "EdgeTSS"
35 | include(":app")
36 | include(":engine")
37 |
--------------------------------------------------------------------------------