diff --git a/app/listening.py b/app/listening.py index b7ccd3a..c31c506 100644 --- a/app/listening.py +++ b/app/listening.py @@ -167,6 +167,7 @@ async def get_listening_exercise( topic: Optional[str] = None, level: Optional[str] = None, user_cards: Optional[List[Dict[str, Any]]] = None, + voice: Optional[str] = None, ) -> Dict[str, Any]: """ Selects or generates a listening exercise, ensuring audio is pre-synthesized. @@ -197,13 +198,13 @@ async def get_listening_exercise( item_id = f"card-{card.get('id', 0)}" # Pre-synthesize audio - story_hash = get_audio_hash(ctx, "en", 1.0) + story_hash = get_audio_hash(ctx, "en", 1.0, voice=voice) question_text = f"Which key word or concept is highlighted in this context?" - question_hash = get_audio_hash(question_text, "en", 1.0) + question_hash = get_audio_hash(question_text, "en", 1.0, voice=voice) # Fire synthesis - asyncio.create_task(synthesize_speech(ctx, lang="en", speed=1.0)) - asyncio.create_task(synthesize_speech(question_text, lang="en", speed=1.0)) + asyncio.create_task(synthesize_speech(ctx, lang="en", speed=1.0, voice=voice)) + asyncio.create_task(synthesize_speech(question_text, lang="en", speed=1.0, voice=voice)) return { "id": item_id, @@ -225,11 +226,11 @@ async def get_listening_exercise( scenario = random.choice(candidates) # Ensure audio is pre-synthesized - story_hash = get_audio_hash(scenario["story"], "en", 1.0) - question_hash = get_audio_hash(scenario["question"], "en", 1.0) + story_hash = get_audio_hash(scenario["story"], "en", 1.0, voice=voice) + question_hash = get_audio_hash(scenario["question"], "en", 1.0, voice=voice) - asyncio.create_task(synthesize_speech(scenario["story"], lang="en", speed=1.0)) - asyncio.create_task(synthesize_speech(scenario["question"], lang="en", speed=1.0)) + asyncio.create_task(synthesize_speech(scenario["story"], lang="en", speed=1.0, voice=voice)) + asyncio.create_task(synthesize_speech(scenario["question"], lang="en", speed=1.0, voice=voice)) return { "id": scenario["id"], diff --git a/app/main.py b/app/main.py index 8e598e8..c1cd763 100644 --- a/app/main.py +++ b/app/main.py @@ -66,6 +66,7 @@ from app.tts import ( prewarm_card_audio, AUDIO_CACHE_DIR, is_piper_available, + get_available_voices, ) from app.listening import ( get_listening_exercise, @@ -523,16 +524,17 @@ async def api_tts( text: str = Query(..., min_length=1, max_length=1000), lang: str = Query("en"), speed: float = Query(1.0, ge=0.5, le=2.0), + voice: Optional[str] = Query(None), user: dict = Depends(require_user), ): """ - Synthesizes speech on-demand using local Piper TTS (en_US-lessac / ru_RU-dmitri). - Instantly returns cached audio if previously synthesized. + Synthesizes speech on-demand using local Piper TTS. + Instantly returns cached audio if previously synthesized for this voice, speed, and text. """ clean_text = text.strip() if not clean_text: raise HTTPException(status_code=400, detail="Text cannot be empty") - audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed) + audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed, voice=voice) if not audio_path or not audio_path.is_file(): raise HTTPException(status_code=500, detail="TTS synthesis failed or unavailable") return FileResponse( @@ -544,13 +546,8 @@ async def api_tts( @app.get("/api/tts/status") async def api_tts_status(user: dict = Depends(require_user)): - """Returns status of local Piper TTS and available voice models.""" - return { - "available": is_piper_available(), - "engine": "piper-vits", - "voices": ["en_US-lessac", "ru_RU-dmitri"], - "cache_dir": str(AUDIO_CACHE_DIR), - } + """Returns status of local Piper TTS and all available voice models.""" + return get_available_voices() # --------------------------------------------------------------------------- @@ -561,6 +558,7 @@ async def api_tts_status(user: dict = Depends(require_user)): async def api_listening_exercise( topic: Optional[str] = Query("all"), level: Optional[str] = Query("B1"), + voice: Optional[str] = Query(None), user: dict = Depends(require_user), ): """ @@ -581,7 +579,7 @@ async def api_listening_exercise( ).fetchall() user_cards = [dict(r) for r in rows] - ex = await get_listening_exercise(topic=topic, level=level, user_cards=user_cards) + ex = await get_listening_exercise(topic=topic, level=level, user_cards=user_cards, voice=voice) # Strip keywords and ideal_answer from client response to prevent inspection safe_ex = {k: v for k, v in ex.items() if k not in ("keywords", "ideal_answer")} return safe_ex diff --git a/app/static/css/style.css b/app/static/css/style.css index 30a7701..e981aff 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -2431,6 +2431,121 @@ body.drawer-open { border-top: 1px solid var(--border-color); } +/* Voice Settings Modal */ +.voice-modal-window { + max-width: 680px; + width: 95%; +} + +.voice-modal-body { + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 16px; + max-height: 80vh; + overflow-y: auto; +} + +.modal-desc-sub { + font-size: 13px; + color: var(--text-muted); + margin: 0 0 10px 0; +} + +.voice-group-title { + font-size: 14px; + font-weight: 700; + color: var(--text-main); + display: block; + margin-bottom: 10px; +} + +.voice-options-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +@media (max-width: 680px) { + .voice-options-grid { + grid-template-columns: 1fr; + } +} + +.voice-card-label { + cursor: pointer; + display: block; + position: relative; + user-select: none; +} + +.voice-card-label input[type="radio"] { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.voice-card { + border: 1px solid var(--border-color); + background: var(--bg-card); + border-radius: var(--radius-md); + padding: 14px 14px; + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; + transition: var(--transition); +} + +.voice-card:hover { + border-color: var(--primary-dim); +} + +.voice-card-label input[type="radio"]:checked + .voice-card { + border-color: var(--primary); + background: var(--bg-main); + box-shadow: 0 0 0 2px var(--primary-dim); +} + +.voice-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; +} + +.voice-card-desc { + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); + flex: 1; +} + +.badge-recommended { + background: rgba(16, 185, 129, 0.15); + color: #10b981; + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.badge-uk { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.voice-test-btn { + align-self: flex-start; + margin-top: 4px; +} + /* Summary Card */ .srs-summary-card { background-color: var(--bg-card); diff --git a/app/static/index.html b/app/static/index.html index a46ea85..d49fbd9 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -512,6 +512,11 @@ + +
+
+ πŸ‘© Π˜Ρ€ΠΈΠ½Π° + РСкомСндуСтся +
+ ΠŸΠ»Π°Π²Π½Ρ‹ΠΉ студийный голос, СстСствСнныС ΠΏΠ°ΡƒΠ·Ρ‹ Π½Π° Ρ‚ΠΈΡ€Π΅ ΠΈ запятых, спокойный Ρ‚Π΅ΠΌΠΏ. + +
+ + + + + + +
+ +
+ + + +
+
+ + + + +