Spaces:
Sleeping
Sleeping
feat: add AI Audio Story Studio tool for prompt-based audiobook synthesis
Browse files- app.py +68 -0
- requirements.txt +3 -0
- templates/layout.html +3 -0
- templates/tools/audio_story_studio/index.html +416 -0
app.py
CHANGED
|
@@ -1583,5 +1583,73 @@ def api_prompt_execute():
|
|
| 1583 |
'error': _err_msg(str(e))
|
| 1584 |
}), 500
|
| 1585 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1586 |
if __name__ == '__main__':
|
| 1587 |
app.run(debug=True, port=5000)
|
|
|
|
| 1583 |
'error': _err_msg(str(e))
|
| 1584 |
}), 500
|
| 1585 |
|
| 1586 |
+
@app.route('/audio-story-studio')
|
| 1587 |
+
def audio_story_studio_index():
|
| 1588 |
+
return render_template('tools/audio_story_studio/index.html')
|
| 1589 |
+
|
| 1590 |
+
@app.route('/api/audio-story-studio/generate', methods=['POST'])
|
| 1591 |
+
def audio_story_studio_generate():
|
| 1592 |
+
data = request.json or {}
|
| 1593 |
+
prompt = data.get('prompt', '').strip()
|
| 1594 |
+
model_id = data.get('gemini_model', DEFAULT_MODEL)
|
| 1595 |
+
|
| 1596 |
+
if not prompt:
|
| 1597 |
+
return jsonify({'error': 'Vui lòng cung cấp ý tưởng hoặc prompt truyện'}), 400
|
| 1598 |
+
|
| 1599 |
+
system_instruction = (
|
| 1600 |
+
"You are an AI Audiobook and Podcast writer.\n"
|
| 1601 |
+
"Your task is to take a story prompt or idea and output a structured JSON containing:\n"
|
| 1602 |
+
"- 'title': A catchy Vietnamese title for the audio story.\n"
|
| 1603 |
+
"- 'script': A high-quality Vietnamese audio script optimized for narration. Write in a natural, engaging prose style, without channelling roleplay markup or action tags in the speech itself. Max 800 words.\n"
|
| 1604 |
+
"- 'suggested_voice': The recommended voice based on the story type. Use 'vi-VN-HoaiMyNeural' (Female, recommended for emotional, romantic, ghost/horror, or family stories) or 'vi-VN-NamMinhNeural' (Male, recommended for crime, action, detective, or historical stories).\n"
|
| 1605 |
+
"- 'rate': The recommended speaking rate (e.g. '-5%' for spooky/horror stories, '+0%' for standard stories, '-10%' for extremely sad/emotional stories).\n"
|
| 1606 |
+
"- 'reason': A brief explanation in Vietnamese of why this voice and pacing were chosen.\n\n"
|
| 1607 |
+
"You MUST return ONLY a valid JSON object matching this structure. Do not wrap in markdown code blocks."
|
| 1608 |
+
)
|
| 1609 |
+
|
| 1610 |
+
try:
|
| 1611 |
+
response = client.models.generate_content(
|
| 1612 |
+
model=model_id,
|
| 1613 |
+
contents=prompt,
|
| 1614 |
+
config=types.GenerateContentConfig(
|
| 1615 |
+
system_instruction=system_instruction,
|
| 1616 |
+
response_mime_type="application/json"
|
| 1617 |
+
)
|
| 1618 |
+
)
|
| 1619 |
+
increment_usage(model_id)
|
| 1620 |
+
|
| 1621 |
+
result = json.loads(response.text.strip())
|
| 1622 |
+
return jsonify({'success': True, **result})
|
| 1623 |
+
except Exception as e:
|
| 1624 |
+
return jsonify({'error': f"Lỗi tạo kịch bản: {_err_msg(str(e))}"}), 500
|
| 1625 |
+
|
| 1626 |
+
@app.route('/api/audio-story-studio/synthesize', methods=['POST'])
|
| 1627 |
+
def audio_story_studio_synthesize():
|
| 1628 |
+
import asyncio, edge_tts
|
| 1629 |
+
data = request.json or {}
|
| 1630 |
+
text = data.get('script', '').strip()
|
| 1631 |
+
voice = data.get('voice', 'vi-VN-HoaiMyNeural')
|
| 1632 |
+
rate = data.get('rate', '+0%')
|
| 1633 |
+
|
| 1634 |
+
if not text:
|
| 1635 |
+
return jsonify({'error': 'Vui lòng cung cấp nội dung kịch bản'}), 400
|
| 1636 |
+
|
| 1637 |
+
out_filename = f"audio_story_{os.urandom(4).hex()}.mp3"
|
| 1638 |
+
out_path = os.path.join(app.config['UPLOAD_FOLDER'], out_filename)
|
| 1639 |
+
|
| 1640 |
+
async def _generate():
|
| 1641 |
+
communicate = edge_tts.Communicate(text, voice, rate=rate)
|
| 1642 |
+
await communicate.save(out_path)
|
| 1643 |
+
|
| 1644 |
+
try:
|
| 1645 |
+
asyncio.run(_generate())
|
| 1646 |
+
return jsonify({
|
| 1647 |
+
'success': True,
|
| 1648 |
+
'audio_url': f'/download/{out_filename}',
|
| 1649 |
+
'filename': out_filename
|
| 1650 |
+
})
|
| 1651 |
+
except Exception as e:
|
| 1652 |
+
return jsonify({'error': f"Lỗi chuyển giọng đọc: {str(e)}"}), 500
|
| 1653 |
+
|
| 1654 |
if __name__ == '__main__':
|
| 1655 |
app.run(debug=True, port=5000)
|
requirements.txt
CHANGED
|
@@ -11,3 +11,6 @@ werkzeug
|
|
| 11 |
gtts
|
| 12 |
edge-tts
|
| 13 |
vieneu
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
gtts
|
| 12 |
edge-tts
|
| 13 |
vieneu
|
| 14 |
+
moviepy
|
| 15 |
+
replicate
|
| 16 |
+
gradio-client
|
templates/layout.html
CHANGED
|
@@ -146,6 +146,9 @@
|
|
| 146 |
<i class="fas fa-wand-magic-sparkles"></i> Tối ưu Prompt thô
|
| 147 |
</a>
|
| 148 |
</div>
|
|
|
|
|
|
|
|
|
|
| 149 |
</nav>
|
| 150 |
</div>
|
| 151 |
|
|
|
|
| 146 |
<i class="fas fa-wand-magic-sparkles"></i> Tối ưu Prompt thô
|
| 147 |
</a>
|
| 148 |
</div>
|
| 149 |
+
<a class="nav-link {% if request.path.startswith('/audio-story-studio') %}active{% endif %}" href="/audio-story-studio">
|
| 150 |
+
<i class="fas fa-podcast"></i> AI Audio Story Studio
|
| 151 |
+
</a>
|
| 152 |
</nav>
|
| 153 |
</div>
|
| 154 |
|
templates/tools/audio_story_studio/index.html
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}AI Audio Story Studio - Pum's Toolkit{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block style %}
|
| 6 |
+
<style>
|
| 7 |
+
.audio-card {
|
| 8 |
+
background: linear-gradient(135deg, #1e1e2f 0%, #11111d 100%);
|
| 9 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 10 |
+
border-radius: var(--radius-lg);
|
| 11 |
+
color: #ffffff;
|
| 12 |
+
position: relative;
|
| 13 |
+
overflow: hidden;
|
| 14 |
+
}
|
| 15 |
+
.audio-card::before {
|
| 16 |
+
content: '';
|
| 17 |
+
position: absolute;
|
| 18 |
+
top: -50%;
|
| 19 |
+
left: -50%;
|
| 20 |
+
width: 200%;
|
| 21 |
+
height: 200%;
|
| 22 |
+
background: radial-gradient(circle, rgba(99, 102, 241, 0.15) 0%, transparent 60%);
|
| 23 |
+
pointer-events: none;
|
| 24 |
+
}
|
| 25 |
+
.recommendation-badge {
|
| 26 |
+
background-color: var(--color-primary-light);
|
| 27 |
+
color: var(--color-primary);
|
| 28 |
+
border: 1px solid rgba(99, 102, 241, 0.2);
|
| 29 |
+
padding: 6px 12px;
|
| 30 |
+
border-radius: 50px;
|
| 31 |
+
font-size: 0.82rem;
|
| 32 |
+
font-weight: 600;
|
| 33 |
+
display: inline-flex;
|
| 34 |
+
align-items: center;
|
| 35 |
+
gap: 6px;
|
| 36 |
+
}
|
| 37 |
+
.audio-player-custom {
|
| 38 |
+
width: 100%;
|
| 39 |
+
background: rgba(255, 255, 255, 0.05);
|
| 40 |
+
border-radius: var(--radius-md);
|
| 41 |
+
padding: 10px;
|
| 42 |
+
margin-top: 15px;
|
| 43 |
+
}
|
| 44 |
+
.audio-player-custom audio {
|
| 45 |
+
width: 100%;
|
| 46 |
+
outline: none;
|
| 47 |
+
}
|
| 48 |
+
.result-box-custom {
|
| 49 |
+
background-color: #f8fafc;
|
| 50 |
+
border: 1px solid #e2e8f0;
|
| 51 |
+
border-radius: var(--radius-md);
|
| 52 |
+
padding: 15px;
|
| 53 |
+
max-height: 400px;
|
| 54 |
+
overflow-y: auto;
|
| 55 |
+
font-size: 0.95rem;
|
| 56 |
+
line-height: 1.6;
|
| 57 |
+
white-space: pre-wrap;
|
| 58 |
+
}
|
| 59 |
+
.voice-badge {
|
| 60 |
+
background: rgba(255, 255, 255, 0.1);
|
| 61 |
+
border-radius: var(--radius-sm);
|
| 62 |
+
padding: 4px 8px;
|
| 63 |
+
font-size: 0.78rem;
|
| 64 |
+
font-weight: 500;
|
| 65 |
+
}
|
| 66 |
+
</style>
|
| 67 |
+
{% endblock %}
|
| 68 |
+
|
| 69 |
+
{% block content %}
|
| 70 |
+
<div id="loaderOverlay" class="loader-overlay" style="display: none;">
|
| 71 |
+
<div class="spinner-pro"></div>
|
| 72 |
+
<p class="mt-3 fw-bold" style="color: var(--color-primary)" id="loaderText">Đang sáng tác kịch bản và thu âm...</p>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div class="mb-4">
|
| 76 |
+
<h2 class="fw-bold"><i class="fas fa-headphones text-primary me-2"></i>AI Audio Story Studio</h2>
|
| 77 |
+
<p class="text-muted">Tự động sáng tác truyện audio ngắn từ ý tưởng, tự động phân tích tông giọng, tốc độ đọc và tạo file âm thanh hoàn chỉnh.</p>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div class="row g-4">
|
| 81 |
+
<!-- Left Column: Settings -->
|
| 82 |
+
<div class="col-lg-5">
|
| 83 |
+
<div class="card prompt-card h-100">
|
| 84 |
+
<div class="card-body p-4 d-flex flex-column justify-content-between">
|
| 85 |
+
<div>
|
| 86 |
+
<h5 class="panel-title mb-4"><i class="fas fa-sliders-h"></i> Cấu hình ý tưởng</h5>
|
| 87 |
+
|
| 88 |
+
<div class="mb-3">
|
| 89 |
+
<label class="form-label fw-bold">Ý tưởng hoặc Chủ đề truyện audio</label>
|
| 90 |
+
<textarea id="storyIdea" class="form-control" rows="6" placeholder="Ví dụ: Một vụ án bí ẩn xảy ra ở một ngôi làng sương mù quanh năm, nhân nhân duy nhất là một cậu bé câm... hoặc một câu chuyện ngoại tình đầy kịch tính của gia đình thượng lưu..."></textarea>
|
| 91 |
+
<div class="form-text small">Viết càng chi tiết, cốt truyện và lời văn sẽ càng sống động và kịch tính.</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<!-- Model Selection -->
|
| 95 |
+
<div class="row g-3 align-items-center mb-4">
|
| 96 |
+
<div class="col-md-6">
|
| 97 |
+
<label class="form-label fw-bold" style="color: var(--color-primary)"><i class="fab fa-google"></i> Mô hình AI</label>
|
| 98 |
+
<select id="geminiModel" class="form-select" style="border-color: var(--color-primary)">
|
| 99 |
+
<option value="">Đang tải...</option>
|
| 100 |
+
</select>
|
| 101 |
+
</div>
|
| 102 |
+
<div class="col-md-6">
|
| 103 |
+
<label class="form-label small text-muted">Hạn ngạch request</label>
|
| 104 |
+
<div id="modelQuota" class="small text-muted" style="min-height: 24px;"></div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<!-- Manual overrides (Optional) -->
|
| 109 |
+
<div class="card bg-light border-0 mb-4">
|
| 110 |
+
<div class="card-body p-3">
|
| 111 |
+
<h6 class="fw-bold small mb-3" style="color: var(--color-text-title)"><i class="fas fa-cog"></i> Tự cấu hình giọng đọc (Nếu muốn bỏ qua AI gợi ý)</h6>
|
| 112 |
+
<div class="row g-2">
|
| 113 |
+
<div class="col-md-7">
|
| 114 |
+
<label class="form-label small mb-1">Chọn Giọng nói</label>
|
| 115 |
+
<select id="manualVoice" class="form-select form-select-sm">
|
| 116 |
+
<option value="auto" selected>-- Để AI tự động phân tích & chọn --</option>
|
| 117 |
+
<option value="vi-VN-HoaiMyNeural">Hoài My (Giọng Nữ Nam Bộ - Truyền cảm / Kinh dị / Drama)</option>
|
| 118 |
+
<option value="vi-VN-NamMinhNeural">Nam Minh (Giọng Nam Bắc Bộ - Hào hùng / Kỳ án / Đanh thép)</option>
|
| 119 |
+
</select>
|
| 120 |
+
</div>
|
| 121 |
+
<div class="col-md-5">
|
| 122 |
+
<label class="form-label small mb-1">Tốc độ (Rate)</label>
|
| 123 |
+
<select id="manualRate" class="form-select form-select-sm">
|
| 124 |
+
<option value="auto" selected>-- Để AI chọn --</option>
|
| 125 |
+
<option value="-15%">Rất chậm (-15%)</option>
|
| 126 |
+
<option value="-8%">Hơi chậm (-8%)</option>
|
| 127 |
+
<option value="+0%">Bình thường (+0%)</option>
|
| 128 |
+
<option value="+10%">Nhanh (+10%)</option>
|
| 129 |
+
</select>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<button id="generateBtn" class="btn-primary-custom w-100" style="padding: 12px 20px;">
|
| 137 |
+
<i class="fas fa-play-circle me-2"></i>TẠO KỊCH BẢN & ĐỌC TRUYỆN
|
| 138 |
+
</button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<!-- Right Column: Outputs -->
|
| 144 |
+
<div class="col-lg-7">
|
| 145 |
+
<!-- Placeholder when no result -->
|
| 146 |
+
<div id="resultPlaceholder" class="card prompt-card h-100 border-dashed d-flex align-items-center justify-content-center p-5 text-center text-muted">
|
| 147 |
+
<div>
|
| 148 |
+
<i class="fas fa-headphones-simple fa-3x mb-3 text-muted" style="opacity: 0.5;"></i>
|
| 149 |
+
<h5>Chưa có truyện nào được tạo</h5>
|
| 150 |
+
<p class="small mb-0">Hãy nhập ý tưởng bên trái và bấm nút để bắt đầu sản xuất truyện audio.</p>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<!-- Result Content (Hidden initially) -->
|
| 155 |
+
<div id="resultContainer" class="d-none d-flex flex-column gap-4">
|
| 156 |
+
<!-- AI Audio Player Card -->
|
| 157 |
+
<div class="card audio-card">
|
| 158 |
+
<div class="card-body p-4">
|
| 159 |
+
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
|
| 160 |
+
<div>
|
| 161 |
+
<span class="recommendation-badge mb-2" id="voiceTypeBadge">
|
| 162 |
+
<i class="fas fa-microphone-alt"></i> Giọng Nữ (Hoài My)
|
| 163 |
+
</span>
|
| 164 |
+
<h4 class="fw-bold m-0 text-white" id="storyTitle">Tên câu chuyện</h4>
|
| 165 |
+
</div>
|
| 166 |
+
<div class="small text-white-50 text-end" style="max-width: 300px;">
|
| 167 |
+
<span class="voice-badge" id="rateBadge">Tốc độ: Normal</span>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<p class="small text-white-50 mb-3" id="voiceReason">
|
| 172 |
+
Lý do lựa chọn: Câu chuyện mang âm hưởng trầm lắng, thích hợp với chất giọng nữ trầm ấm...
|
| 173 |
+
</p>
|
| 174 |
+
|
| 175 |
+
<!-- Player -->
|
| 176 |
+
<div class="audio-player-custom">
|
| 177 |
+
<audio id="audioPlayer" controls></audio>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
<!-- Audio Actions -->
|
| 181 |
+
<div class="d-flex gap-2 mt-3">
|
| 182 |
+
<a id="downloadAudioBtn" href="#" download class="btn btn-light btn-sm flex-grow-1 fw-bold">
|
| 183 |
+
<i class="fas fa-download me-1"></i> Tải về file Audio (MP3)
|
| 184 |
+
</a>
|
| 185 |
+
<button id="reSynthesizeBtn" class="btn btn-outline-light btn-sm flex-grow-1 fw-bold">
|
| 186 |
+
<i class="fas fa-sync-alt me-1"></i> Đọc lại (Nếu sửa kịch bản dưới)
|
| 187 |
+
</button>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
+
<!-- Narration Script Card -->
|
| 193 |
+
<div class="card prompt-card">
|
| 194 |
+
<div class="card-body p-4">
|
| 195 |
+
<div class="d-flex justify-content-between align-items-center mb-3">
|
| 196 |
+
<h5 class="panel-title m-0"><i class="fas fa-file-alt"></i> Kịch bản phát thoại (Bản viết)</h5>
|
| 197 |
+
<button id="copyScriptBtn" class="btn btn-outline-primary btn-sm">
|
| 198 |
+
<i class="fas fa-copy"></i> Sao chép văn bản
|
| 199 |
+
</button>
|
| 200 |
+
</div>
|
| 201 |
+
<p class="text-muted small mb-3">Bạn có thể chỉnh sửa trực tiếp nội dung dưới đây và nhấn nút <strong>"Đọc lại"</strong> ở trên để thu âm lại theo kịch bản mới.</p>
|
| 202 |
+
<textarea id="storyScript" class="form-control" rows="12" style="font-family: var(--font-monospace); font-size: 0.95rem; line-height: 1.6; border-radius: var(--radius-md);"></textarea>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<div id="errorArea" class="mt-4 d-none">
|
| 210 |
+
<div class="alert alert-danger d-flex align-items-center gap-2 mb-0">
|
| 211 |
+
<i class="fas fa-exclamation-circle"></i>
|
| 212 |
+
<span id="errorMsg"></span>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
{% endblock %}
|
| 216 |
+
|
| 217 |
+
{% block scripts %}
|
| 218 |
+
<script>
|
| 219 |
+
let geminiModelList = [];
|
| 220 |
+
let activeModel = '';
|
| 221 |
+
|
| 222 |
+
// Load models list
|
| 223 |
+
async function loadModels() {
|
| 224 |
+
try {
|
| 225 |
+
const res = await fetch('/api/gemini-models');
|
| 226 |
+
geminiModelList = await res.json();
|
| 227 |
+
const sel = document.getElementById('geminiModel');
|
| 228 |
+
sel.innerHTML = '';
|
| 229 |
+
geminiModelList.forEach(m => {
|
| 230 |
+
const opt = document.createElement('option');
|
| 231 |
+
opt.value = m.id;
|
| 232 |
+
const quotaText = m.remaining > 0
|
| 233 |
+
? `✓ Còn ${m.remaining}`
|
| 234 |
+
: `✗ Hết (${m.used}/${m.rpd})`;
|
| 235 |
+
opt.textContent = `${m.name} (${quotaText})`;
|
| 236 |
+
if (m.remaining === 0) opt.disabled = true;
|
| 237 |
+
sel.appendChild(opt);
|
| 238 |
+
});
|
| 239 |
+
updateModelQuota();
|
| 240 |
+
} catch(e) {
|
| 241 |
+
console.error('Lỗi tải danh mục model:', e);
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
function updateModelQuota() {
|
| 246 |
+
const sel = document.getElementById('geminiModel');
|
| 247 |
+
const chosen = geminiModelList.find(m => m.id === sel.value);
|
| 248 |
+
const el = document.getElementById('modelQuota');
|
| 249 |
+
if (chosen) {
|
| 250 |
+
const status = chosen.remaining > 0
|
| 251 |
+
? `<span style="color: var(--color-primary)"><i class="fas fa-check-circle"></i> Còn <strong>${chosen.remaining}</strong> request</span>`
|
| 252 |
+
: `<span style="color: #dc3545"><i class="fas fa-exclamation-triangle"></i> Đã hết quota</span>`;
|
| 253 |
+
el.innerHTML = status;
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
document.getElementById('geminiModel').addEventListener('change', updateModelQuota);
|
| 258 |
+
loadModels();
|
| 259 |
+
|
| 260 |
+
function showError(msg) {
|
| 261 |
+
const area = document.getElementById('errorArea');
|
| 262 |
+
document.getElementById('errorMsg').textContent = msg;
|
| 263 |
+
area.classList.remove('d-none');
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function clearError() {
|
| 267 |
+
document.getElementById('errorArea').classList.add('d-none');
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
let generatedScriptDetails = null;
|
| 271 |
+
|
| 272 |
+
// Generate Action
|
| 273 |
+
document.getElementById('generateBtn').addEventListener('click', async () => {
|
| 274 |
+
const idea = document.getElementById('storyIdea').value.trim();
|
| 275 |
+
if (!idea) {
|
| 276 |
+
showError('Vui lòng nhập ý tưởng/concept truyện của bạn!');
|
| 277 |
+
return;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
const loader = document.getElementById('loaderOverlay');
|
| 281 |
+
const loaderText = document.getElementById('loaderText');
|
| 282 |
+
loaderText.textContent = 'Đang dùng Gemini viết kịch bản truyện audio...';
|
| 283 |
+
loader.style.display = 'flex';
|
| 284 |
+
clearError();
|
| 285 |
+
|
| 286 |
+
try {
|
| 287 |
+
// Step 1: Generate Script and Settings via Gemini
|
| 288 |
+
const model_id = document.getElementById('geminiModel').value;
|
| 289 |
+
const res = await fetch('/api/audio-story-studio/generate', {
|
| 290 |
+
method: 'POST',
|
| 291 |
+
headers: { 'Content-Type': 'application/json' },
|
| 292 |
+
body: JSON.stringify({ prompt: idea, gemini_model: model_id })
|
| 293 |
+
});
|
| 294 |
+
const data = await res.json();
|
| 295 |
+
|
| 296 |
+
if (data.error || !data.success) {
|
| 297 |
+
showError(data.error || 'Lỗi khi tạo kịch bản.');
|
| 298 |
+
loader.style.display = 'none';
|
| 299 |
+
return;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
generatedScriptDetails = data;
|
| 303 |
+
|
| 304 |
+
// Populate fields in UI
|
| 305 |
+
document.getElementById('storyTitle').textContent = data.title;
|
| 306 |
+
document.getElementById('storyScript').value = data.script;
|
| 307 |
+
document.getElementById('voiceReason').textContent = `AI khuyến nghị: ${data.reason}`;
|
| 308 |
+
|
| 309 |
+
// Determine voice to use (Auto or manual override)
|
| 310 |
+
let voiceToUse = document.getElementById('manualVoice').value;
|
| 311 |
+
if (voiceToUse === 'auto') {
|
| 312 |
+
voiceToUse = data.suggested_voice || 'vi-VN-HoaiMyNeural';
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
let rateToUse = document.getElementById('manualRate').value;
|
| 316 |
+
if (rateToUse === 'auto') {
|
| 317 |
+
rateToUse = data.rate || '+0%';
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
// Update badge info
|
| 321 |
+
const voiceLabel = voiceToUse === 'vi-VN-HoaiMyNeural' ? 'Giọng Nữ (Hoài My)' : 'Giọng Nam (Nam Minh)';
|
| 322 |
+
document.getElementById('voiceTypeBadge').innerHTML = `<i class="fas fa-microphone-alt"></i> ${voiceLabel}`;
|
| 323 |
+
document.getElementById('rateBadge').textContent = `Tốc độ: ${rateToUse}`;
|
| 324 |
+
|
| 325 |
+
// Step 2: Synthesize audio automatically
|
| 326 |
+
loaderText.textContent = 'Đang chuyển đổi kịch bản thành giọng đọc trí tuệ nhân tạo...';
|
| 327 |
+
await runSynthesis(data.script, voiceToUse, rateToUse);
|
| 328 |
+
|
| 329 |
+
// Show results
|
| 330 |
+
document.getElementById('resultPlaceholder').classList.add('d-none');
|
| 331 |
+
document.getElementById('resultContainer').classList.remove('d-none');
|
| 332 |
+
await loadModels();
|
| 333 |
+
} catch(e) {
|
| 334 |
+
showError('Đã xảy ra lỗi kết nối. Hãy thử lại.');
|
| 335 |
+
} finally {
|
| 336 |
+
loader.style.display = 'none';
|
| 337 |
+
}
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
// Synthesize function
|
| 341 |
+
async function runSynthesis(script, voice, rate) {
|
| 342 |
+
const res = await fetch('/api/audio-story-studio/synthesize', {
|
| 343 |
+
method: 'POST',
|
| 344 |
+
headers: { 'Content-Type': 'application/json' },
|
| 345 |
+
body: JSON.stringify({ script: script, voice: voice, rate: rate })
|
| 346 |
+
});
|
| 347 |
+
const data = await res.json();
|
| 348 |
+
|
| 349 |
+
if (data.error || !data.success) {
|
| 350 |
+
throw new Error(data.error || 'Lỗi khi tổng hợp âm thanh.');
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
const audioUrl = data.audio_url;
|
| 354 |
+
const player = document.getElementById('audioPlayer');
|
| 355 |
+
player.src = audioUrl;
|
| 356 |
+
player.load();
|
| 357 |
+
|
| 358 |
+
const downloadBtn = document.getElementById('downloadAudioBtn');
|
| 359 |
+
downloadBtn.href = audioUrl;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
// Re-synthesize button (manual trigger after script edit)
|
| 363 |
+
document.getElementById('reSynthesizeBtn').addEventListener('click', async () => {
|
| 364 |
+
const script = document.getElementById('storyScript').value.trim();
|
| 365 |
+
if (!script) {
|
| 366 |
+
showError('Kịch bản đang trống!');
|
| 367 |
+
return;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
const loader = document.getElementById('loaderOverlay');
|
| 371 |
+
const loaderText = document.getElementById('loaderText');
|
| 372 |
+
loaderText.textContent = 'Đang cập nhật giọng đọc với kịch bản mới...';
|
| 373 |
+
loader.style.display = 'flex';
|
| 374 |
+
clearError();
|
| 375 |
+
|
| 376 |
+
// Get voice & speed
|
| 377 |
+
let voiceToUse = document.getElementById('manualVoice').value;
|
| 378 |
+
if (voiceToUse === 'auto') {
|
| 379 |
+
voiceToUse = (generatedScriptDetails && generatedScriptDetails.suggested_voice) || 'vi-VN-HoaiMyNeural';
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
let rateToUse = document.getElementById('manualRate').value;
|
| 383 |
+
if (rateToUse === 'auto') {
|
| 384 |
+
rateToUse = (generatedScriptDetails && generatedScriptDetails.rate) || '+0%';
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
try {
|
| 388 |
+
await runSynthesis(script, voiceToUse, rateToUse);
|
| 389 |
+
|
| 390 |
+
// Play audio automatically after synthesis
|
| 391 |
+
document.getElementById('audioPlayer').play();
|
| 392 |
+
} catch(e) {
|
| 393 |
+
showError(e.message || 'Lỗi khi dịch giọng đọc.');
|
| 394 |
+
} finally {
|
| 395 |
+
loader.style.display = 'none';
|
| 396 |
+
}
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
// Copy Script
|
| 400 |
+
document.getElementById('copyScriptBtn').addEventListener('click', () => {
|
| 401 |
+
const text = document.getElementById('storyScript').value;
|
| 402 |
+
if (!text) return;
|
| 403 |
+
|
| 404 |
+
navigator.clipboard.writeText(text).then(() => {
|
| 405 |
+
const btn = document.getElementById('copyScriptBtn');
|
| 406 |
+
const originalHTML = btn.innerHTML;
|
| 407 |
+
btn.innerHTML = '<i class="fas fa-check"></i> Đã sao chép!';
|
| 408 |
+
setTimeout(() => {
|
| 409 |
+
btn.innerHTML = originalHTML;
|
| 410 |
+
}, 2000);
|
| 411 |
+
}).catch(err => {
|
| 412 |
+
showError('Không thể copy tự động.');
|
| 413 |
+
});
|
| 414 |
+
});
|
| 415 |
+
</script>
|
| 416 |
+
{% endblock %}
|