Add logic to provide tts to posts

This commit is contained in:
2025-07-01 15:42:37 +10:00
parent 5d08053080
commit 9ec4a0d996
5 changed files with 507 additions and 13 deletions
BIN
View File
Binary file not shown.
+435
View File
@@ -76,6 +76,174 @@
.think-icon.expanded {
transform: rotate(180deg);
}
/* Enhanced Audio Player Styles */
.audio-player {
background: linear-gradient(145deg, #f8fafc 0%, #e2e8f0 100%);
border: 1px solid #cbd5e1;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.audio-controls {
display: flex;
align-items: center;
gap: 12px;
}
.play-pause-btn {
width: 48px;
height: 48px;
background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
border: none;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.play-pause-btn:hover {
background: linear-gradient(145deg, #334155 0%, #1e293b 100%);
transform: translateY(-1px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
}
.play-pause-btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.play-pause-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stop-btn {
width: 36px;
height: 36px;
background: linear-gradient(145deg, #dc2626 0%, #991b1b 100%);
border: none;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 3px 6px rgba(220, 38, 38, 0.3);
}
.stop-btn:hover {
background: linear-gradient(145deg, #ef4444 0%, #dc2626 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.4);
}
.stop-btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3);
}
.speed-btn {
width: 44px;
height: 32px;
background: linear-gradient(145deg, #059669 0%, #047857 100%);
border: none;
border-radius: 16px;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);
font-size: 11px;
font-weight: 700;
font-family: 'Monaco', 'Menlo', monospace;
}
.speed-btn:hover {
background: linear-gradient(145deg, #10b981 0%, #059669 100%);
transform: translateY(-1px);
box-shadow: 0 3px 6px rgba(5, 150, 105, 0.4);
}
.speed-btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);
}
.progress-container {
position: relative;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #7c3aed 0%, #a855f7 100%);
border-radius: 4px;
transition: width 0.1s ease;
box-shadow: 0 1px 3px rgba(124, 58, 237, 0.3);
}
.seek-slider {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
margin: 0;
}
.seek-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: #7c3aed;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.audio-time {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 14px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.audio-status {
font-size: 12px;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-ready { background: #ddd6fe; color: #7c3aed; }
.status-generating { background: #fef3c7; color: #d97706; }
.status-playing { background: #dcfce7; color: #16a34a; }
.status-paused { background: #fed7aa; color: #ea580c; }
.status-stopped { background: #f3f4f6; color: #6b7280; }
.status-finished { background: #dbeafe; color: #2563eb; }
.status-error { background: #fecaca; color: #dc2626; }
</style>
{% endblock %}
@@ -120,6 +288,14 @@
<!-- Action Buttons - Responsive positioning -->
<div class="flex items-center space-x-2 sm:flex-shrink-0">
<button onclick="toggleTTS()"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-purple-700 hover:text-purple-800 bg-purple-50 hover:bg-purple-100 rounded-md transition duration-150">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 14.142M9 9v6l4-3-4-3z"/>
</svg>
<span id="tts-button-text">{% trans "Listen" %}</span>
</button>
<a href="{% url 'post-update' post.pk %}"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-blue-700 hover:text-blue-800 bg-blue-50 hover:bg-blue-100 rounded-md transition duration-150">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -191,6 +367,57 @@
</div>
</div>
</div>
<!-- TTS Audio Player - Initially Hidden -->
<div id="tts-player" class="mt-4 audio-player hidden">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-gray-800">{% trans "Audio Player" %}</h4>
<div id="tts-status" class="audio-status status-ready">{% trans "Ready" %}</div>
</div>
<!-- Audio Controls -->
<div class="space-y-4">
<audio id="tts-audio" class="hidden" preload="none">
Your browser does not support the audio element.
</audio>
<!-- Progress Bar -->
<div class="progress-container">
<div id="progress-bar" class="progress-bar" style="width: 0%"></div>
<input id="seek-slider" type="range" min="0" max="100" value="0" class="seek-slider">
</div>
<!-- Control Buttons and Time -->
<div class="flex items-center justify-between">
<div class="audio-controls">
<button id="play-pause-btn" onclick="togglePlayPause()"
class="play-pause-btn"
disabled>
<svg id="play-icon" class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
</svg>
<svg id="pause-icon" class="w-6 h-6 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</button>
<button onclick="stopAudio()" class="stop-btn">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"/>
</svg>
</button>
<button id="speed-btn" onclick="toggleSpeed()" class="speed-btn" title="{% trans 'Playback Speed' %}">
1x
</button>
</div>
<div class="audio-time">
<span id="current-time">0:00</span> / <span id="total-time">0:00</span>
</div>
</div>
</div>
</div>
</div>
<!-- Content Section - Reduced padding, wider content -->
@@ -242,5 +469,213 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
});
// TTS Functionality
let currentAudio = null;
let isLoading = false;
let playbackSpeeds = [1, 1.25, 1.5, 2];
let currentSpeedIndex = 0;
function toggleTTS() {
const player = document.getElementById('tts-player');
const buttonText = document.getElementById('tts-button-text');
if (player.classList.contains('hidden')) {
player.classList.remove('hidden');
buttonText.textContent = '{% trans "Hide Player" %}';
if (!currentAudio) {
loadTTSAudio();
}
} else {
player.classList.add('hidden');
buttonText.textContent = '{% trans "Listen" %}';
if (currentAudio) {
currentAudio.pause();
}
}
}
function loadTTSAudio() {
if (isLoading) return;
isLoading = true;
const status = document.getElementById('tts-status');
const playBtn = document.getElementById('play-pause-btn');
status.textContent = '{% trans "Generating..." %}';
status.className = 'audio-status status-generating';
playBtn.disabled = true;
fetch('{% url "post-tts" post.pk %}', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json',
},
})
.then(response => {
if (!response.ok) {
throw new Error('TTS generation failed');
}
return response.blob();
})
.then(blob => {
const audioUrl = URL.createObjectURL(blob);
const audio = document.getElementById('tts-audio');
audio.src = audioUrl;
currentAudio = audio;
audio.addEventListener('loadedmetadata', function() {
status.textContent = '{% trans "Ready" %}';
status.className = 'audio-status status-ready';
playBtn.disabled = false;
updateTotalTime();
setupAudioEvents();
});
audio.addEventListener('error', function() {
status.textContent = '{% trans "Error" %}';
status.className = 'audio-status status-error';
playBtn.disabled = true;
});
audio.load();
})
.catch(error => {
console.error('TTS Error:', error);
status.textContent = '{% trans "Failed" %}';
status.className = 'audio-status status-error';
playBtn.disabled = true;
})
.finally(() => {
isLoading = false;
});
}
function setupAudioEvents() {
if (!currentAudio) return;
currentAudio.addEventListener('timeupdate', updateProgress);
currentAudio.addEventListener('ended', onAudioEnded);
// Set initial playback rate
currentAudio.playbackRate = playbackSpeeds[currentSpeedIndex];
// Seek functionality
const seekSlider = document.getElementById('seek-slider');
seekSlider.addEventListener('input', function() {
if (currentAudio) {
const seekTime = (this.value / 100) * currentAudio.duration;
currentAudio.currentTime = seekTime;
}
});
}
function togglePlayPause() {
if (!currentAudio) return;
const playIcon = document.getElementById('play-icon');
const pauseIcon = document.getElementById('pause-icon');
const status = document.getElementById('tts-status');
if (currentAudio.paused) {
currentAudio.play();
playIcon.classList.add('hidden');
pauseIcon.classList.remove('hidden');
status.textContent = '{% trans "Playing" %}';
status.className = 'audio-status status-playing';
} else {
currentAudio.pause();
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
status.textContent = '{% trans "Paused" %}';
status.className = 'audio-status status-paused';
}
}
function stopAudio() {
if (!currentAudio) return;
currentAudio.pause();
currentAudio.currentTime = 0;
const playIcon = document.getElementById('play-icon');
const pauseIcon = document.getElementById('pause-icon');
const status = document.getElementById('tts-status');
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
status.textContent = '{% trans "Stopped" %}';
status.className = 'audio-status status-stopped';
updateProgress();
}
function toggleSpeed() {
if (!currentAudio) return;
// Cycle through speeds
currentSpeedIndex = (currentSpeedIndex + 1) % playbackSpeeds.length;
const newSpeed = playbackSpeeds[currentSpeedIndex];
// Update audio playback rate
currentAudio.playbackRate = newSpeed;
// Update button text
const speedBtn = document.getElementById('speed-btn');
speedBtn.textContent = newSpeed + 'x';
// Visual feedback
speedBtn.style.transform = 'scale(1.1)';
setTimeout(() => {
speedBtn.style.transform = '';
}, 150);
}
function updateProgress() {
if (!currentAudio) return;
const progress = (currentAudio.currentTime / currentAudio.duration) * 100;
document.getElementById('progress-bar').style.width = progress + '%';
document.getElementById('seek-slider').value = progress;
document.getElementById('current-time').textContent = formatTime(currentAudio.currentTime);
}
function updateTotalTime() {
if (!currentAudio) return;
document.getElementById('total-time').textContent = formatTime(currentAudio.duration);
}
function onAudioEnded() {
const playIcon = document.getElementById('play-icon');
const pauseIcon = document.getElementById('pause-icon');
const status = document.getElementById('tts-status');
playIcon.classList.remove('hidden');
pauseIcon.classList.add('hidden');
status.textContent = '{% trans "Finished" %}';
status.className = 'audio-status status-finished';
// Reset speed to 1x when audio ends
currentSpeedIndex = 0;
const speedBtn = document.getElementById('speed-btn');
speedBtn.textContent = '1x';
if (currentAudio) {
currentAudio.playbackRate = 1;
}
}
function formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return mins + ':' + (secs < 10 ? '0' : '') + secs;
}
</script>
{% endblock %}
+1
View File
@@ -41,6 +41,7 @@ urlpatterns = [
path('ui/posts/<int:pk>/', post_views.PostDetailView.as_view(), name='post-detail'),
path('ui/posts/<int:pk>/edit/', post_views.PostUpdateView.as_view(), name='post-update'),
path('ui/posts/<int:pk>/delete/', post_views.PostDeleteView.as_view(), name='post-delete'),
path('ui/posts/<int:post_id>/tts/', views.generate_tts, name='post-tts'),
# Collection slideshow
path('ui/collections/<uuid:pk>/slideshow/',
+63 -5
View File
@@ -29,12 +29,10 @@ from wsgiref.util import FileWrapper
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.mixins import LoginRequiredMixin
import requests
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import csrf_exempt
from bs4 import BeautifulSoup
from urllib.parse import urlparse
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from .serializers import PageSerializer
from .page_views import (
PageListView, PageDetailView, PageCreateView,
PageUpdateView, PageDeleteView, fetch_page_info,
@@ -510,3 +508,63 @@ def export_database(request):
class HelpView(TemplateView):
template_name = 'links/help.html'
@require_http_methods(["POST"])
def generate_tts(request, post_id):
"""Generate TTS audio for a post"""
try:
post = get_object_or_404(Post, pk=post_id)
# Clean the post content - remove markdown and HTML
clean_text = clean_text_for_tts(post.content)
if not clean_text.strip():
return JsonResponse({'error': 'No text content to convert'}, status=400)
# Call TTS API
import urllib.parse
encoded_text = urllib.parse.quote(clean_text)
tts_url = f"https://home-tts.junv.workers.dev/tts?text={encoded_text}&haha=hahaJunv"
try:
response = requests.get(tts_url, timeout=30)
response.raise_for_status()
# Return the audio file
audio_response = HttpResponse(response.content, content_type='audio/mpeg')
audio_response['Content-Disposition'] = f'inline; filename="post_{post_id}_tts.mp3"'
return audio_response
except requests.RequestException as e:
return JsonResponse({'error': f'TTS service error: {str(e)}'}, status=500)
except Post.DoesNotExist:
return JsonResponse({'error': 'Post not found'}, status=404)
except Exception as e:
return JsonResponse({'error': f'Server error: {str(e)}'}, status=500)
def clean_text_for_tts(content):
"""Clean markdown and HTML from text for TTS"""
# Convert markdown to HTML first
html = markdown.markdown(content)
# Parse HTML and extract text
soup = BeautifulSoup(html, 'html.parser')
# Remove script and style elements
for script in soup(["script", "style"]):
script.decompose()
# Get text and clean it
text = soup.get_text()
# Clean up whitespace and special characters
text = re.sub(r'\s+', ' ', text) # Multiple spaces to single
text = re.sub(r'\n+', '. ', text) # Multiple newlines to period
text = text.strip()
# Limit text length (TTS services often have limits)
if len(text) > 3000:
text = text[:3000] + "..."
return text
Generated
+8 -8
View File
@@ -244,16 +244,16 @@ wheels = [
[[package]]
name = "django"
version = "5.0.11"
version = "5.0.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/9c/614ef004c122cfdf9f2d291eab75e24ab94d24c0693a0ca0db44e9633dc3/Django-5.0.11.tar.gz", hash = "sha256:e7d98fa05ce09cb3e8d5ad6472fb602322acd1740bfdadc29c8404182d664f65", size = 10635979, upload-time = "2025-01-14T14:28:58.251Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/cc0205045386b5be8eecb15a95f290383d103f0db5f7e34f93dcc340d5b0/Django-5.0.14.tar.gz", hash = "sha256:29019a5763dbd48da1720d687c3522ef40d1c61be6fb2fad27ed79e9f655bc11", size = 10644306, upload-time = "2025-04-02T11:24:41.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/d7/da8d8f62f33c3cd45ad7e9cb456d7c95457d15ee26166d2d033f461aedf7/Django-5.0.11-py3-none-any.whl", hash = "sha256:09e8128f717266bf382d82ffa4933f13da05d82579abf008ede86acb15dec88b", size = 8185849, upload-time = "2025-01-14T14:28:46.345Z" },
{ url = "https://files.pythonhosted.org/packages/c0/93/eabde8789f41910845567ebbff5aacd52fd80e54c934ce15b83d5f552d2c/Django-5.0.14-py3-none-any.whl", hash = "sha256:e762bef8629ee704de215ebbd32062b84f4e56327eed412e5544f6f6eb1dfd74", size = 8185934, upload-time = "2025-04-02T11:24:36.888Z" },
]
[[package]]
@@ -338,14 +338,14 @@ wheels = [
[[package]]
name = "gunicorn"
version = "22.0.0"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/88/e2f93c5738a4c1f56a458fc7a5b1676fc31dcdbb182bef6b40a141c17d66/gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63", size = 3639760, upload-time = "2024-04-16T22:58:19.218Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/97/6d610ae77b5633d24b69c2ff1ac3044e0e565ecbd1ec188f02c45073054c/gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9", size = 84443, upload-time = "2024-04-16T22:58:15.233Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
]
[[package]]
@@ -863,7 +863,7 @@ requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0" },
{ name = "boto3", specifier = ">=1.35.0" },
{ name = "celery", specifier = ">=5.3.0" },
{ name = "django", specifier = "==5.0.11" },
{ name = "django", specifier = "==5.0.14" },
{ name = "django-simplemde", specifier = "==0.1.4" },
{ name = "django-tailwind", specifier = "==3.8.0" },
{ name = "django-widget-tweaks", specifier = "==1.5.0" },
@@ -871,7 +871,7 @@ requires-dist = [
{ name = "flake8", marker = "extra == 'dev'", specifier = ">=6.0" },
{ name = "flower", specifier = ">=2.0.0" },
{ name = "fontawesome-free", specifier = "==5.15.3" },
{ name = "gunicorn", specifier = "==22.0.0" },
{ name = "gunicorn", specifier = "==23.0.0" },
{ name = "isort", marker = "extra == 'dev'", specifier = ">=5.0" },
{ name = "markdown", specifier = "==3.7" },
{ name = "pillow", specifier = "~=11.2.1" },