From 270aba89a096f9a26f8d9dac4b6ac8e43dbf9cd5 Mon Sep 17 00:00:00 2001 From: TTS Service Date: Fri, 27 Mar 2026 13:41:07 +0800 Subject: [PATCH] first commit: TTS Book Service with MiMo TTS integration --- .env.example | 7 + Dockerfile | 17 + README.md | 96 ++++++ app/__pycache__/main.cpython-312.pyc | Bin 0 -> 27925 bytes app/config.py | 21 ++ app/main.py | 436 ++++++++++++++++++++++++ app/models.py | 42 +++ app/static/index.html | 480 +++++++++++++++++++++++++++ docker-compose.yml | 27 ++ requirements.txt | 6 + 10 files changed, 1132 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__pycache__/main.cpython-312.pyc create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/static/index.html create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..278ecb3 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# 小米 MiMo TTS API Key(必填) +MIMO_API_KEY=your_api_key_here + +# 以下为可选配置(有默认值) +# MIMO_API_ENDPOINT=https://api.xiaomimimo.com/v1/chat/completions +# MIMO_TTS_MODEL=mimo-v2-audio-tts +# MIMO_VOICE=mimo_default diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4cecacb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-alpine + +# 安装 ffmpeg(alpine 版很小) +RUN apk add --no-cache ffmpeg + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ . + +RUN mkdir -p /app/data /app/audio + +EXPOSE 17200 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "17200"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..993fe65 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# 📚 TTS Book Service + +基于**小米 MiMo TTS**的听书音频转换服务,提供 Web 管理界面和听书 App 音频接入接口。 + +## 架构 + +``` +听书 App ──HTTP──▶ 本服务 ──API──▶ 小米 MiMo TTS + │ + ├── SQLite (书籍/章节管理) + └── MP3 文件缓存 +``` + +## 快速启动 + +```bash +# 1. 配置 API Key +cp .env.example .env +# 编辑 .env 填入你的 MIMO_API_KEY + +# 2. 启动 +docker compose up -d + +# 3. 访问管理界面 +# http://your-server:17200 +``` + +## 功能 + +### Web 管理界面 (`/`) +- 📖 书籍管理(添加/删除) +- 📑 章节管理(添加/编辑/删除) +- 🎙️ TTS 试听(支持风格设置) +- ⚡ 单章/批量音频生成 +- ⚙️ 配置查看 + +### 听书 App 接口 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/api/book/{book_id}` | GET | 获取书籍信息和章节列表 | +| `/api/book/{book_id}/chapter/{chapter_id}/audio` | GET | 下载章节 MP3 音频 | + +### 管理 API + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/admin/api/books` | GET/POST | 书籍列表/创建 | +| `/admin/api/books/{book_id}` | DELETE | 删除书籍 | +| `/admin/api/books/{book_id}/chapters` | GET/POST | 章节列表/创建 | +| `/admin/api/books/{book_id}/chapters/{id}` | PUT/DELETE | 更新/删除章节 | +| `/admin/api/books/{book_id}/chapters/{id}/generate` | POST | 生成单章音频 | +| `/admin/api/books/{book_id}/generate-all` | POST | 批量生成 | +| `/admin/api/tts/preview` | POST | TTS 试听 | +| `/admin/api/config` | GET | 查看配置 | + +## 接入听书 App + +1. 在管理界面添加书籍和章节 +2. 为章节生成音频 +3. 在听书 App 配置: + - **接入方式**: 音频方式 + - **接入地址**: `http://your-server:17200` + - **音频类型**: mp3 + - **书籍地址**: `http://your-server:17200/api/book/{book_id}` + +## 环境变量 + +| 变量 | 必填 | 默认值 | 说明 | +|------|------|--------|------| +| `MIMO_API_KEY` | ✅ | - | 小米 MiMo TTS API Key | +| `MIMO_API_ENDPOINT` | ❌ | `https://api.xiaomimimo.com/v1/chat/completions` | API 地址 | +| `MIMO_TTS_MODEL` | ❌ | `mimo-v2-audio-tts` | 模型名称 | +| `MIMO_VOICE` | ❌ | `mimo_default` | 默认音色 | +| `SERVER_PORT` | ❌ | `17200` | 服务端口 | + +## MiMo TTS 风格参考 + +在「TTS 试听」中可填写风格,例如: + +| 类别 | 示例 | +|------|------| +| 情感 | 开心 / 悲伤 / 生气 / 平静 | +| 语速 | 语速慢 / 语速快 / 悄悄话 | +| 角色 | 像个大将军 / 像个小孩 / 孙悟空 | +| 方言 | 东北话 / 四川话 / 台湾腔 / 粤语 | + +## 不使用 Docker 运行 + +```bash +pip install -r requirements.txt +# 需要系统安装 ffmpeg +export MIMO_API_KEY=your_key +cd app +python main.py +``` diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff643c49afbb3252c7e13881239fa8e63a63de33 GIT binary patch literal 27925 zcmdsg33wFOnP63SbyeS|?gO>U1_`h|GrQaE)=bMC^2N>oHuHV^O>@j(d%WN7|G(<0 zZZ$2y$~_1^!EdiCCay+eOzFlZ^bj;5dM{Ir^){s|wXi;E)HHmWG<2*p#a z6i@StZn~8wcSWm$+?B0La%Wl@a#yvg$enFv;jZlFdep6I8q+e}njUSdmf(tRU8@el zRNeX>L#v_3*lO%CwVHa&t!5I>c3XO^t=1k}tF0%cHHCz6-Kjlkt!V^Tcc=Gcv}W{V zwr2KZwPy8Xw`TX`wC42Ww&wQCYMs@S*P2J@G~M=|{MP)Qg4Tkb!q!3(rtO~HQ`A~S za7A}mQVklsck-3EA?^*!eGXmwi`LH!JTo_o;=yIkkR?!{f5vGhuaKqSQ1yla!9i( zMk`_bJh=q%ZA#EUC@K`^FFON&N64HHFs#WaHFNiM-TaKh< z`Gs+5Yi4-2o6;hAnju|fd^%|VC2?tLXGpVDP9uJ^WpQch zAWgC!xLi(C71t~3fqH+e0ew3S#^_U0NbG)MrJPgsi5h9F(bNp(u5#N>+$D_< zzBaCGr+YPD_sCp|63Ph&ThqD*N?p_SqgYuI#@9n#o|B$pcL;qU_9S6^!->Y%8S#mx zb}M!(9IN}cz_YfcWwX7mukR82X1DNYXS>^8X1_Rg@Uu66ZNIzo?mjz4fBx=?OV9l5 z(y?bQKJ~Kp!pYxVJapp1+yBX4yJLs_(!oO){^rHYFa7%R&kkSu;mnKu&vFrySKf)uh;9|HyWN}Io*jL?9(P#3rseMSv8T10H#AF6kh^ZP zx6Rwxjww72b(pR1>+bLA4RcLU3n)L#ZuSbDz1zb~3sfqsZfNtmTRMB(VP;!@Z+n>a zxVzo$-Z0C<-Rp+3ZS8^^Xk6r7T<+fOoxMXAYOg~)HOXk zAEDe72tTS-v73e*7|gO6>Zf+OdfR&3E>~FVa`p7_{oRP`U9Ru+w{?p#uTj@9!>@F0 zJ9d=!v~~8*-?3ZBg%ASr0P%p7rM^c^F>0Ye3ZGUL7DDm5_6~qpRd~8p3B8nYt9S*k zIH8pKe=Ccj@Z)$!9v=!Gs7|m_Uu;#&G@MMMI-!4?%*$W@N$KC6_ z%tMw=Xl1s)w-b6}Sl!#--QBje+Z|SQ_VVssm#@P|a?nC4&|UrjK$v=s4s*PFTU&p( z7f_=I#+F-fMMIh$CSewk3E1j_9TON_E-&UNek;L%5k`zqbn=1;5e}>B>g2;JZ>JZE zW}%K9eS%;?QY%s@d;4~VwPH`@U2R?=AEU^pEJUnG0{G;42mpNSltC?qbVmamQml(#b8%=mTIq-EvawPMLwX!1_<0yNs6*@?t1q=c;Dit9qL zZZ@4F)`?;iTwEt3od~)R$HzKZd^~n9#;xb$!weX9i!??#UMqK8U0j)_Bzc-im<6D< zt5|%yc$lO!r`%bJ+vXTkCR2AC9i}i>-_f?i3!GBjE+U>5VGh(qC;{MfC`exy$}nUB zf`tg?1Gt<5Ak6J>_wvACg~jj$)D;-0?(6N}!2=s@cBCcrf1wfxV!d8if{)nkg{7EM z8?6L(ehE!&x(xZd{779GR*BjQh_4A#5u5BO!fT4pp==X#*Zb9f1Ts@xc zv0)=rVFoKmF=F+SjU6yeC;iiV-<#c*0C!#$7q5*P^5KiIUFkPdO6+J4=i|CEZ;t0vrFIjn)9RGwoe z0N*~YUuY-@PseaRgTPDzvk1&axJT{HmdfeMk+9g^r2#erhLONO@5*lgn$5B9s?}D7m&NXH!DT9?i7( z$?<8XzX@;Lqj^lTlio>*x&BhQlXm3xBQyMb?1|5w8%@-(K>_}kcMg31-g6gU{HguE z+I#JyMt$*@M?QaQAK|P0$XxGkx_g5QXkF_X@3&t%_KVBkf9A7ye}yUEc!um{>B?sb~F&|14=MZE|&sfSVg#bKPo^ny+jN3@4#2{2srG+T(r_c3qCO* zgxdOZcg!OP=rD&{W?TR&{v2J9dS2#6YO;?nHUS?CA4&!xlNC#^#2N8=9J1!a7kn zf#8CMXo9Q-w9CTE?QXC@wt|Acq#~@@x}<`4BSl!f&b_-4#aLL|gvzIgYp}fu>d=ar zti_x*VNTqRw%y%*ZM<+lK7rLC)<*zSN_ZH9G@_`4GQzamgZ?VnP^(#`rvH|xAHd$``8e^1c3$*B1$4xM9_y zox>d?-jQtqW7)}u!PVDU%9;_f6bCHDQ#tu3oT1X{0Q|SqOwDqPZ4ac@_)}J0;TRJ; zxH@8_GUuJQWrl3U0bB7El@bH5YbkST$Yc+g?4!0xQ`rrQR<+U*0>{#(QnQadaQK1a zX`%e(f&AsCIR8Cc4m}V|y+35Se~7uEMA~QymQw3OTzY^@A71t8hhLPGpO7Y0hhJBE6@r{}v<`QjvYb-N<^Mb8;H1CyJPx04U+pSq20c#GjaGJCp+sC&+?b&p zpGP;Qu;Zl+;tO;bzJhK{W5!q3mczrD(xS#P>a0Zp_}LU1h|Z?bh-a{kCEVFrbYs5$ zY#xVrF^A!0Db7^oIojkjE6m6GJOMe4A3e-rvM9+1xT!A<3D2^lrVwNO%dbb^Eq$Jv-dn!GZDv!o|UH0`3y~-5VDJ$0bn%in87C z>l{#*;b?BJY71+-JGa8?cZ2;tpeg%~2Zov(g6Tyqz$K0CpoaIObD-b5qu(nIZDeWv zsQ{wIiT)XhZ8Zz65am#XwVwX1JA^)9f}XHa=>_9HI$yfMq4yJ&35 zTh(t?pGrAZ=wIygFIw%-UK2Dn`PEGkC8eH4%t>eu(HsFwz~%7@?Q)lZD&XgP0)E#P zfZ`cdQRR3^Ss^MMJxzrJg|rJ_GU1Aec(1IEN}s(8)!~FIM%9HZG~x15=+=|cOLtKH z#JWhTA9hhR{u3^#_Is2*rN^qlsSNlhDQKYDOnW16@X0ee)8$s1uUQi%J8zlBtDKYeSpJ%AG_{ zQ3Jqc_AoxCD?b({VZcgM;H-+84m9O0lwy2P_hjE06l?A$3d~a~b&u+fQmGSCv0ikY zkSqDOl=7F`o{5?doEI&1F{KJT8qwNUg8J-JcR8fE*xe_)-pGVhT+f0xo+r_Dq3N4& zNquLJ#;55*6)xf8X)n45$mLV{WVc^pouM35j>*+kAz`t*PbIxAP5D^pHL@ANYvcP6 zFz&yg&)&YrE~O4B4rBqO4}=JOi@@Ckf)s)9W-vG=(BPP6UK|$<0f^fPCI^<7)<1VhZ#5_kdug{|5-|c=FUwgEVX%BeBd(7B?GPc9-{;1JIe|JWsXEz;Zo*4S^ z`+~T-bl}O0zxwfKuaAJ~@!4}PT^c$h8YBPm&JbEAfBM0XP@w(GJNui32Ovd1TB4U; zc=FP*6BmE*{fobP8?yN9wcmxc+uglxbbR-2NB=4)p#rGEp0L`z3rr1H-y>f!^Ff#Z z1X&VR?A#hwfsdWvE&7wt+HvH9BNkokuBcNz%(-{D+xx*SWrJwqjtQ%FcDMz%PzM2F zE6UC`0UYTdF9laG+=T{=0KbONxkGSbs?`_(dObSY1=I!v7lvqRztzyR!PU^TNpv}> zdLH3Bg|HG*5_LDs^np(ciW54$U>%Wqb@qjgpryOqkHVX|y8HTegiRo4U7fuyv7+Ff z-HVwk`aCF3d-}i$+(=53CpsKDVILvkw+e%ZfZ-x~-lAGL)FMtOYf%1f-R*UI!t}N< zgJlW=B=Z2nK`AX=qO?wnegn7cFe942@Cp!jVX6(l8$|uxl=JlCH$ZF1iXa#%m`cw) zGH`g{c=3t!SIR>LO9KT<-{C^b)&`cX{h&9n?7?8cL&5ZiLzah!*eOuvt@8rbc|mL0 z5cjbuZAx#wp@Cdt`9<`UIXz@5444W-CP%>Jm^96s%AGy5`atuPy<%wfq&|1bnsUT( z*zxSVkUrNBkB~>|^gJ@Ctl}Z=fN{!bKG?ax^XW%I>P){nGs0wO%Fbux9J@D^TNcPI z3ueq8OMffx&AdRyT|-S%`qYCP_HP(z7^@iCFsWa5Aw6q&`RJTb$;v>_9S8!HsD5G|$>AWTNP{#-pv=o8)VJIB6Oqnf5 zxWnAAX3|^$wuYhbx|&MK4%r+5ndZ?}^rv8K;&7t?NVj^?v<& zD8OJk*tEatP{*V`?~{B7w#zKJnF>fVMYNR3694))Hq%lwUBOTH<&@>&FC$9CB38;U z>&u9Z;8~OgLQ<&AI{J$n3aA*V@K=zZ;coiN>t@QFd6iOV%A)0+w-=7ozQT@pN0lS` zq1EtF0{Yy4kEnp+D^TAwSzkm*!C;F_EB#l|n#O!qshZT94;eM!9~v}G zRjLouvYIMYA1=U3e^{x)kSfgo!&R8}Z)tX|jr&`UUaQsrt(rsJ#6fsi3I3Ct6j+!^ zqpnRs6DBIK$!s1k@-K3A$ttQ80$w!wgzQ}d0SkGGSJIrMNGh4#(}mn6;fgCenb%Y) zsT~Qmkd@L~siH(lr*2Zxk?SX1UfF}$m6i|%k6jrzhe?VAs32kqQPmq%AM#JCHz?K9 zYJl;-WL}_;(4bzUcX1v3a^Po-3R-GrL3?0UoSF zdk>LE!jCYFA;vM&1#^*MbzSY|MwCxsHah>T+UkaR=&%|^4vI-Kk&FI0ZyP$eq^Up` zxNcwy<~Yd^{8_yo=wg&)(5bsSw}B(5tydU>P^^h(7=S1g81~6^d)Gb3hRpVW**;qF z56pt|(J%#axg+|+`eXOL#Hhnl_e#E0m5!3mw8$O9iIdTwJ^%TpuLpiF94%T&qit?71kS27k6 zOD{7E_(IW3Dp|VAmWgDMqh)vt%QxA?hxCnMaww^d2%)mQD%FDm?LL|9>QgmP2ibPT zcB)r^d*r@O{L=9+TtYAP>9@W{)zhcXYhV4)hi$tggQjve4 z?IAhZ`-N@?L->Xmw;ZWz0646|0mPp{Kx`cJ0SG@t08@LEa1a5CdeQh%_X*&(1ABl> zn1?yh{sC_vu>82J>o5VS3f98GiFy45@UV`sG11c(X7C}bY431}0i;f0MSF(_TO(>( zh?a!`?H-W@_QG7!yAVGLKTjXD9g!WN%$jr2q{vcfBM88x$eH!h+M{caKYD_H#TS~h zJTPbZJG0*_eY^C7If1Hsf^#+nb2f+4HV^5hOj#jQS-?~lG?foAADe9Ft$8ELk)5ME zM?1#`{6%#^YrS7z|4CNPOC?83&g-quEq!`JNS`0j=O3T*YWd6MWA_CLRtELeA-yx8 zcb?h#{=n&hpnlUer6OBM#=Z1=x28x$X<>tM# z<>;2FS@xGYk9K|n=F{%OyGK@kls*sKm$T=OZXdmWtn-xOeZy(P$;Us~7RdkB)WXVZ z2F_x=s)SsxXiR49jdW-z(qvb$$-bOQ+x%rj3-$f#sufGlgFq08644dJzVVX4O!Sw6luf+;BYd}U{@o-Bua74FU8p@?GfETisU}-4AWIBf zzC@X@S=COaV0X#B zIk7DeCNEoRrGz(<{jK|GFHX{uD|rq$%|m35A~wwvl_n;T9B;$##oJ4d1Fy%a=h}Ys zV~Wy46c^FetuH)z;je!?lh6?U3KIP*>O*3h>C&RV5F(;e`eQ$aiF?tn1{3!e-+lJt z{%1aW>bI8$_g2}7FD<^f3nzaD(>ep@1NIpNAd zSXY=JKX8^dPJR$c5ECLO4v0gFm>|Nd@Ceog`sX|X&I5oLBVvanLwP}5RKiaIBa20e zNDu{r1qFgM&_yrP1dx4zq-i$^1y(gbLIIHdOw*Y7d2{*^(_z!d(tx?-1UOu30;M&l zN&= zlvNtYDxK7oMRTkS8khLhOD^crhq+KjNg$(S%;&%70sqDa{SSWI-`qB-+v;bwM)IK| zUq%#ARWN4&2i`5$53#R3TcCaRnHp9C?{2UI?*{Do+LVSl%F_ zC*kwllI2dbgZEHA>OY?4-f9v|VjI#u(URYTh6fJCgiB(wxAHvT95E=|%BaF!3<@{0 zZ1iy_To9UsokFP09AgQ1`j`@w;<-dAhP-d*X&|eYehbWNdsGot5=Hn9JPo_!R~#vt zUsEQinGSs-haei}Z=tTD4$1ItPrL9IKA%KD#y_!uQA-82HO2;@ai}o8)q_qb-@1tty5peYX34Wdv0OI}3O4UQKUIU@b0l?Fo@=9sc)6Bfbz0JL+e_Q|8#$dtf zVEUSnWeqr*&1pxBhmAqAJ!GC8FwYK}i-%NG_df8CrU(7^Y#Oe8sp)9b82uLaCU7%PgpmhhHSv=AwLA`_wTUwr z;Gkg0e!(tPVs4IezYGuWsSsJ<}61hR68HU7(Ps%wv>N>2m*xc|>6y46i?? zxHFCgCj1KES-*qdqwvdz-#6u0hGvoBEP&d@4A{$upx5`K(?hg4dHcm59k7#a5Oz?s zK7Zm}anAAbQJ@ok4RrrXO!kX!{Z-UtKMP^FoJP_22q^%Zt|)nh4g`k~um}>ArzFNV znASWm90p>CTKEvtJd9vEmn1wgI?;)X;C}}s9u&98CGiqDr7pUu2wzGFK=eN&-7F$4O=lx^b|FqMdtPbPdOR58-1DV)-{kbZ$~v@l2N6o8IrlGSWk zO2l((6^%K{i9A@&#!lF4)8OIE5?I2{6!e3c%k&g$sKEcUFPMI08m0ZgMI_G|`$ zStKNfZJf`YEub5V^k)k>#7j7!9LP#kx?-;4k!*qGq{;p$b$GI2tLYeaene$KVeB<%g9zQiFP~H^GTpKj53#r%n)$3xE z%!@gp%z1&#c_$j)SpVAkldA)z^}$SB!x~cKGSz%^OICZ}jiQ${x}i%QQ!f}h?OW|u?-;*pJYpj^d>z0 zNCS*EYjP?u$mlGJR}4V5y}INycUlspMpY%@f>3)x5IiQQ<&~&R^2{+c7>&;pqY-CO zZZka{^?NM=?(`2)vymZYqv@ORDu=3>?8X>S&YwS@Om+a%A|9Z6sIjP(K~uuq0oiW{ zo~P~Jj<~f|nvOP)OPmF<#4$TEosr;;P4O1W0#bysN!S6NeTe6H9E~7b-PIgbVn(OI|?QhvX0aot_j)- zL$)~q+nk`y0aFSYRC?aOrR&YwNE&6aMQl`F{!2Yad!`CzhYA)43KpZbm2ouVd~L(2 zP4Bx-yMndf@^5+IquK{X6t9|IHjOc3Hov{{r0OGkwZE-B)V3qgw&Q$W!N{UlSG~L{ zn74QeWv3;gSw1FEr;LU(NUz^tmHgE;q2sUtKG2r9bbs3F%_RL}hJe;Z2 z1JPO7P+(!tDpbiG@41eZhA9i)>%j4C!HGvt<9?(spMP>}J>zA*BPi`kmO# zxT{Z>*xnTNzu9gk%i`l~>ty19utTZ#5<{22xHM6Zi<^_!zfupGW?QRyjZX=-wRQ&E z8Ybg?s$H}PrlHZw_OWo{K-5zLOP!PMcJ#?x#~}@uY!XDONn*`mWs%;eGh z_|+?-a>|S=ssP9a`b=`_o$aBTje(kt{+5RWH4g{%E|@yWLNS%1B~#q`l$)&ffzj@h zCH|tupw;QuJ1;cSSh^!%x8$4A*qM_Z??=~J0S*LAE-JH)}ZJdKXn@jDNg5{-eh ztkIHysq#iXRP+Yc_6h=!dT7U60sR7GPYO(lfmM>quf(;QFK}Ym&H<`v6fmq39|-KddGf)3&5a*@Y@5wKf`a}P&bpAW#K(|6HyJ0aX8VT5ap*auR=0K zJH|kXpOf(USVqL%5M32SC}T`DrehNZid>&~FeM+RQ9Lq3n)wy}00cG2JuS((=Xuf5 zXh!b@fZOWZ$@o~jx;<_77q%V{7o@*vNzl5~uV4C!E_1jgpv#{soHJ?+6fVUT^!6#8 z^$@>*)l`msB>m{!um$1nDP8ukH=rw=DsuQssscqTkgnj9>DvsZqCO7bYT)AlfLtvH z*7oS_z|R0hu7(r6GFL-)$28)vJ!oI+U&6$%N(tL> z>sP^c+}b>Nn8>FaXm+B2L)@VUqKQhnLCH=mVG*yU5w4;k_6$v+5<||Y*oJiOjE-(F z>d)vo#BCgeCs(o2d+`nYk25INaXvrnV&szfjjV1;F~FZ>s+oO$aWwL1jpcA>{05XU z#jPfTlZ@|g;uqWx<9adta^a`@W_Gp^gm<~EKah+^GC-qKxg-FGr3a;hJegpOdIcpl z=8q8Bf&=j-K;j*PDja$MWXIq)Q6WsILTJENM!+WQM3F{_4sySNMO;OI!$JeYf+fOU zgCQi6fGbEL&9>Gp152|m8!S!0U=>7NA+Ca`%Y{`C>43w)sLO+a!6B~E1I>6UU6;>} zr?H6V(iomcU_Ol@g=}3VHx4c>ks#20c9KCt|o%Jp(-8&&cF^qV9D$VW?^A39jV z=G+;?`ZOxyX#0O#tc&%PtS?+8aVxQY+l6hs;_;-**FZccu?czQF(p|g@fr-gVffX- zZ}uH5^{R+l>c#Y?kDrCgHF_Jito$6bk${4&=WEgbd{CETzjI$qH&qLrkfkDUHfhM&&?{ zzvC~Ksm7F$p*UbD4jSeTDbT0yK##ox*IK2I6ujzq*>SP}m+c00tNqOCh=Ed-!5{|; zNdhVnCus;kSHI(H&vvLkxcW12kgs5VWCiRvU02QiTnX4{ZA!gTd3q&Xw~9Sojrh1l z2jSy6bUn?C=jI_^O(R@IL+k`d$qX77TBLI5|JUm$v>f6V4#EfK++v7J?OO(mq^EiI zWsMzt^Jhy2HAd3&nHu%ByO~J8tHWFpIY^>?8sr=Lxr&g2(tp`{E)@+2<`>fcUVoy_3Ir znSQF&*>Mw?q@;USwF{){4P(0AS%io?TNiq&#n&J+e7ybcEE7 zfZBnF)|7w-VM{-58+jm@zwp$&fUeokG)Gb)w>Y*_LfDR22(q1$udnJUTjeKe$5);{ zcvo0zNdf<+;48nAHCni0Nn%ck!#e&HKCi6o zCv!7MjDhFK!5uTW6QU>o03*i>cE8FFg=E@<)Dp?29f_A1wj#w28}X_BqTF-|2Yqny zIs4dYM~B3ncL!(V$>+esQ66~UMy~jF@z5t9a0*`KX{;LBAWx#~vP)R(F15iPtxwxk z7z>v$ILZT=A-Q}UoV3S?c;c6Ya^^(IsIIvZ7Q4rG`2cKRwngo{>a6+iRg%(VvYHNIaw> ze!&==n;adHEyDMac0Yn41Wy6L1=Og6l}l9RQBQVQiCef*lX|i}m>SzAwvShM79PV) ze?Okyi?AZB>~QZAow>zxkgh18 zD+=o7z_PTNR-b*UGbOB*Pb(a07%d8>EeM$x3^tuNr#yFWD77e%S_I2@N3#Ov#e+># zjN!=*dp8U>j8+V8m}C}Qu%;a;Ib8B=X~aY17k>KJB%nq1tHoIPThQfm%!`?*7! zNp%)(LCyxXS`Ta4^1)ve)D^<3X^kQ6tblgbu=jZDSjNd^L2W~bY49@*xCgpvZ_`l6 zB$NJ$HO-&DaBS{amOpQ0Kv(T&sv~8@(`}?I3%|T!hMY->cxr;Gk^aiF8d`f_iqi%@ zpvG0qk=lGfPcNgL3g-0kx@>qjlVWv(L#KEt;AeAbr-eB?s{rtG3fSL8pHr!vM(&(e z4fud1PQHjo6}Cg}@+FHFG&G_<9y#(h#{fi!w@eNQ_Ac>CcHBT*tn5pc=%FuW+&Vsf z1qNtw%x2K>#Ji}8HDAV;8DL7_>PLT)eEcOGBCm$ihIhawd9Sb;VuTF<@SNaIJj~nG z)8>JV^)Nr>!nkb++7N6-@NES72nrA&-@}t@$T7lj@S|`Q0)C7`;{iY#0V$W0tQE*!bwE1Tv&q{k#h+NKb^J=PrXebN1gJ*|9~W~VISED z21oaUwcr<@2jaTr%zLvwcruU)FNJ}sx9SC&*cEe!~gL-f0t_j&XDNrt%74kzz_*D z9eCg<96Kt&Iii9aPXL8;Cr~ar)`vNli|=c9xy07k0)av&g6|^uX9OsX;0Ouv?>4{@ zg?N|&IZM>rfloxjVYdr&+u$!az+e;5T_vug<`Bbf6ShPn=S%%n+*l@HGYdMTut^kn z3~FaPZe{Zbr~$(Hh2QCJ>u&FG_w0^48XS8NIqy5}2yayGgcMAZ7E3c9PPCpMJ*P=P z%Th=~fOaDs$S-F-A5Ov*GB5^vLRjBW+frMHM{Rs-(|X|-7*4!B_!-0#^s5nj4rx{7 zASQA=lqjuV!Kg6=#}RZOco)G0f)5b55d;uSBKR1=?*W9Bc)a5u@bQlbK1FZ|0ZM1W zhwva?IJA)*4MI8{IYd3o^~0a-=o5Mc9}vP{zG&QZZ{sG{nhl%bXxZq)#toZV61dz~ z>XqW(iKr2*(0AcK<#`-FF_>C3eMO<9nQK~#*8ZN-{hl)Xp0W_c_y?-w4^+lKQTjhp zRY9uikCZb&IsZV-`ydne8J4H~YqG_4t0b)ACSbu~*@4^>>J;C9`rr#I0<{B;WNR|;VFF+JQK zfr)u1Jqjlm;eD+C8o5WDG@PwN58EOX-baa@4y_%rU8UfLY3w8*QX>;B z57Jc;g%!B@$mR%z_p$b?@s2j3`6syg7m70f)$g%ZEW#X_`HPnE}BHZia6=j8-uhnqEORoXe bytes: + """调用小米 MiMo TTS API,返回 WAV 音频字节""" + if not config.MIMO_API_KEY: + raise HTTPException(500, "MIMO_API_KEY 未配置,请设置环境变量") + + content = f"{text}" if style else text + + payload = { + "model": config.MIMO_TTS_MODEL, + "audio": {"format": "wav", "voice": config.MIMO_VOICE}, + "messages": [{"role": "assistant", "content": content}], + } + + headers = { + "Content-Type": "application/json", + "api-key": config.MIMO_API_KEY, + } + + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post(config.MIMO_API_ENDPOINT, json=payload, headers=headers) + + if resp.status_code != 200: + raise HTTPException(502, f"MiMo TTS API 错误: HTTP {resp.status_code} - {resp.text[:300]}") + + data = resp.json() + if data.get("error"): + raise HTTPException(502, f"MiMo TTS 错误: {data['error']}") + + try: + audio_b64 = data["choices"][0]["message"]["audio"]["data"] + return base64.b64decode(audio_b64) + except (KeyError, IndexError, TypeError) as e: + raise HTTPException(502, f"MiMo TTS 响应解析失败: {e}") + + +def wav_to_mp3(wav_path: str, mp3_path: str): + """用 ffmpeg 将 WAV 转为 MP3""" + result = subprocess.run( + ["ffmpeg", "-y", "-i", wav_path, "-codec:a", "libmp3lame", "-qscale:a", "2", mp3_path], + capture_output=True, text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"ffmpeg 转换失败: {result.stderr[:300]}") + + +async def generate_chapter_audio(chapter_id_str: str): + """为指定章节生成音频(WAV → MP3)""" + async with async_session() as db: + result = await db.execute(select(Chapter).where(Chapter.chapter_id == chapter_id_str)) + chapter = result.scalar_one_or_none() + if not chapter: + return + + if not chapter.text_content.strip(): + chapter.status = "error" + chapter.error_msg = "文本内容为空" + await db.commit() + return + + chapter.status = "generating" + await db.commit() + + try: + audio_dir = Path(config.AUDIO_DIR) / chapter.book_id + audio_dir.mkdir(parents=True, exist_ok=True) + + wav_path = str(audio_dir / f"{chapter.chapter_id}.wav") + mp3_path = str(audio_dir / f"{chapter.chapter_id}.mp3") + + # MiMo TTS 生成 WAV + wav_bytes = await call_mimo_tts(chapter.text_content) + with open(wav_path, "wb") as f: + f.write(wav_bytes) + + # WAV → MP3 + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path) + + # 删除 WAV 源文件,只保留 MP3 + os.remove(wav_path) + + chapter.audio_file = mp3_path + chapter.status = "ready" + chapter.error_msg = "" + except Exception as e: + chapter.status = "error" + chapter.error_msg = str(e)[:500] + + await db.commit() + + +# ── App Lifecycle ────────────────────────────────────────────────────────── + +@asynccontextmanager +async def lifespan(app: FastAPI): + os.makedirs(config.AUDIO_DIR, exist_ok=True) + os.makedirs(os.path.join(config.BASE_DIR, "data"), exist_ok=True) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + + +app = FastAPI(title="TTS Book Service", lifespan=lifespan) + + +# ── 听书 App 音频接入接口 ───────────────────────────────────────────────── + +@app.get("/api/book/{book_id}") +async def get_book_info(book_id: str): + """获取书籍信息及章节列表(听书 App 调用)""" + async with async_session() as db: + book_result = await db.execute(select(Book).where(Book.book_id == book_id)) + book = book_result.scalar_one_or_none() + if not book: + raise HTTPException(404, f"书籍 {book_id} 不存在") + + ch_result = await db.execute( + select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.id) + ) + chapters = ch_result.scalars().all() + + return { + "book_id": book.book_id, + "title": book.title, + "author": book.author, + "chapters": [ + { + "chapter_id": ch.chapter_id, + "app_chapter_id": ch.app_chapter_id, + "title": ch.title, + "status": ch.status, + "audio_url": f"/api/book/{book_id}/chapter/{ch.chapter_id}/audio" + if ch.status == "ready" else None, + } + for ch in chapters + ], + } + + +@app.get("/api/book/{book_id}/chapter/{chapter_id}/audio") +async def get_chapter_audio(book_id: str, chapter_id: str): + """获取章节音频文件(听书 App 调用)""" + async with async_session() as db: + result = await db.execute( + select(Chapter).where( + Chapter.book_id == book_id, Chapter.chapter_id == chapter_id + ) + ) + chapter = result.scalar_one_or_none() + + if not chapter: + raise HTTPException(404, "章节不存在") + if chapter.status != "ready" or not chapter.audio_file: + raise HTTPException(404, f"音频尚未生成,当前状态: {chapter.status}") + + if not os.path.exists(chapter.audio_file): + raise HTTPException(404, "音频文件丢失") + + return FileResponse(chapter.audio_file, media_type="audio/mpeg", filename=f"{chapter_id}.mp3") + + +# ── 管理 API ────────────────────────────────────────────────────────────── + +# --- Books --- + +@app.get("/admin/api/books") +async def list_books(): + async with async_session() as db: + result = await db.execute(select(Book).order_by(Book.id.desc())) + books = result.scalars().all() + return [{"book_id": b.book_id, "title": b.title, "author": b.author} for b in books] + + +@app.post("/admin/api/books") +async def create_book(request: Request): + data = await request.json() + book_id = data.get("book_id", "").strip() + title = data.get("title", "").strip() + author = data.get("author", "").strip() + + if not book_id or not title: + raise HTTPException(400, "book_id 和 title 不能为空") + + async with async_session() as db: + existing = await db.execute(select(Book).where(Book.book_id == book_id)) + if existing.scalar_one_or_none(): + raise HTTPException(409, f"书籍 {book_id} 已存在") + + book = Book(book_id=book_id, title=title, author=author) + db.add(book) + await db.commit() + return {"ok": True, "book_id": book_id} + + +@app.delete("/admin/api/books/{book_id}") +async def delete_book(book_id: str): + async with async_session() as db: + await db.execute(delete(Chapter).where(Chapter.book_id == book_id)) + await db.execute(delete(Book).where(Book.book_id == book_id)) + await db.commit() + return {"ok": True} + + +# --- Chapters --- + +@app.get("/admin/api/books/{book_id}/chapters") +async def list_chapters(book_id: str): + async with async_session() as db: + result = await db.execute( + select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.id) + ) + chapters = result.scalars().all() + return [ + { + "chapter_id": ch.chapter_id, + "app_chapter_id": ch.app_chapter_id, + "title": ch.title, + "text_content": ch.text_content[:200] + "..." if len(ch.text_content) > 200 else ch.text_content, + "text_length": len(ch.text_content), + "status": ch.status, + "error_msg": ch.error_msg, + "has_audio": ch.status == "ready", + } + for ch in chapters + ] + + +@app.post("/admin/api/books/{book_id}/chapters") +async def create_chapter(book_id: str, request: Request): + data = await request.json() + chapter_id = data.get("chapter_id", "").strip() + title = data.get("title", "").strip() + app_chapter_id = data.get("app_chapter_id", "").strip() + text_content = data.get("text_content", "").strip() + + if not chapter_id: + raise HTTPException(400, "chapter_id 不能为空") + + async with async_session() as db: + existing = await db.execute( + select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(409, f"章节 {chapter_id} 已存在") + + chapter = Chapter( + book_id=book_id, + chapter_id=chapter_id, + app_chapter_id=app_chapter_id or chapter_id, + title=title, + text_content=text_content, + ) + db.add(chapter) + await db.commit() + return {"ok": True, "chapter_id": chapter_id} + + +@app.put("/admin/api/books/{book_id}/chapters/{chapter_id}") +async def update_chapter(book_id: str, chapter_id: str, request: Request): + data = await request.json() + async with async_session() as db: + result = await db.execute( + select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id) + ) + chapter = result.scalar_one_or_none() + if not chapter: + raise HTTPException(404, "章节不存在") + + if "text_content" in data: + chapter.text_content = data["text_content"] + if "title" in data: + chapter.title = data["title"] + if "app_chapter_id" in data: + chapter.app_chapter_id = data["app_chapter_id"] + + await db.commit() + return {"ok": True} + + +@app.delete("/admin/api/books/{book_id}/chapters/{chapter_id}") +async def delete_chapter(book_id: str, chapter_id: str): + async with async_session() as db: + await db.execute( + delete(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id) + ) + await db.commit() + return {"ok": True} + + +# --- TTS --- + +@app.post("/admin/api/books/{book_id}/chapters/{chapter_id}/generate") +async def generate_audio(book_id: str, chapter_id: str): + """手动生成单章音频""" + await generate_chapter_audio(chapter_id) + async with async_session() as db: + result = await db.execute( + select(Chapter).where(Chapter.book_id == book_id, Chapter.chapter_id == chapter_id) + ) + ch = result.scalar_one_or_none() + return {"ok": True, "status": ch.status, "error_msg": ch.error_msg} + + +@app.post("/admin/api/books/{book_id}/generate-all") +async def generate_all_chapters(book_id: str): + """批量生成书籍所有章节音频""" + async with async_session() as db: + result = await db.execute( + select(Chapter).where(Chapter.book_id == book_id, Chapter.status != "ready") + ) + chapters = result.scalars().all() + + chapter_ids = [ch.chapter_id for ch in chapters] + for cid in chapter_ids: + await generate_chapter_audio(cid) + + return {"ok": True, "total": len(chapter_ids), "chapter_ids": chapter_ids} + + +# --- TTS 试听 --- + +@app.post("/admin/api/tts/preview") +async def tts_preview(request: Request): + """试听 TTS 效果""" + data = await request.json() + text = data.get("text", "").strip() + style = data.get("style", "").strip() + + if not text: + raise HTTPException(400, "文本不能为空") + + wav_bytes = await call_mimo_tts(text, style) + audio_dir = Path(config.AUDIO_DIR) / "_preview" + audio_dir.mkdir(parents=True, exist_ok=True) + + filename = f"{uuid.uuid4().hex}.mp3" + wav_path = str(audio_dir / f"{uuid.uuid4().hex}.wav") + mp3_path = str(audio_dir / filename) + + with open(wav_path, "wb") as f: + f.write(wav_bytes) + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, wav_to_mp3, wav_path, mp3_path) + os.remove(wav_path) + + return {"ok": True, "url": f"/audio/_preview/{filename}"} + + +@app.get("/admin/api/config") +async def get_config(): + return { + "endpoint": config.MIMO_API_ENDPOINT, + "model": config.MIMO_TTS_MODEL, + "voice": config.MIMO_VOICE, + "api_key_masked": config.MIMO_API_KEY[:6] + "****" if config.MIMO_API_KEY else "未配置", + } + + +# ── 静态文件 & 前端 ────────────────────────────────────────────────────── + +app.mount("/audio", StaticFiles(directory=config.AUDIO_DIR), name="audio") + + +@app.get("/", response_class=HTMLResponse) +async def frontend(): + html_path = os.path.join(config.BASE_DIR, "static", "index.html") + with open(html_path, "r", encoding="utf-8") as f: + return HTMLResponse(f.read()) + + +# ── Main ────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host=config.SERVER_HOST, port=config.SERVER_PORT, reload=True) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..6bfd13d --- /dev/null +++ b/app/models.py @@ -0,0 +1,42 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, func +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +import config + +engine = create_async_engine(config.DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +class Book(Base): + __tablename__ = "books" + + id = Column(Integer, primary_key=True, autoincrement=True) + book_id = Column(String(100), unique=True, nullable=False, index=True) # 平台书籍ID + title = Column(String(500), nullable=False) # 书名 + author = Column(String(200), default="") # 作者 + created_at = Column(DateTime, server_default=func.now()) + + +class Chapter(Base): + __tablename__ = "chapters" + + id = Column(Integer, primary_key=True, autoincrement=True) + book_id = Column(String(100), nullable=False, index=True) # 关联的书籍ID + chapter_id = Column(String(100), nullable=False, index=True) # 平台音频ID + app_chapter_id = Column(String(100), default="") # App章节ID + title = Column(String(500), default="") # 章节标题 + text_content = Column(Text, default="") # TTS 文本内容 + audio_file = Column(String(500), default="") # 生成的音频文件路径 + status = Column(String(20), default="pending") # pending / generating / ready / error + error_msg = Column(Text, default="") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..c69cf89 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,480 @@ + + + + + +TTS Book Service + + + +
+

📚 TTS Book Service

+

小米 MiMo TTS 听书音频转换服务

+ +
+ + + +
+ + +
+
+
+
📖 书籍列表
+ +
+
+
+

加载中...

+
+
+ + +
+
+
🎙️ TTS 试听
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+
⚙️ 当前配置
+ + + + + + +
配置项
TTS API-
模型-
默认音色-
API Key-
+

通过环境变量配置:MIMO_API_KEY、MIMO_API_ENDPOINT、MIMO_TTS_MODEL、MIMO_VOICE

+
+
+
+ + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e64efb2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: "3.8" + +services: + tts-book-service: + build: . + container_name: tts-book-service + ports: + - "17200:17200" + environment: + - MIMO_API_KEY=${MIMO_API_KEY} + - MIMO_API_ENDPOINT=${MIMO_API_ENDPOINT:-https://api.xiaomimimo.com/v1/chat/completions} + - MIMO_TTS_MODEL=${MIMO_TTS_MODEL:-mimo-v2-audio-tts} + - MIMO_VOICE=${MIMO_VOICE:-mimo_default} + - SERVER_PORT=17200 + volumes: + - tts-data:/app/data + - tts-audio:/app/audio + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + restart: unless-stopped + +volumes: + tts-data: + tts-audio: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c7b8ef3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy==2.0.35 +aiosqlite==0.20.0 +httpx==0.27.0 +python-multipart==0.0.12