Initial commit for TTS project
This commit is contained in:
66
podcast_audios/project_chapter8_demo/config/README_DEMO.txt
Normal file
66
podcast_audios/project_chapter8_demo/config/README_DEMO.txt
Normal file
@@ -0,0 +1,66 @@
|
||||
MOSS-TTSD Podcast Player Demo - 使用说明
|
||||
=========================================
|
||||
|
||||
📁 文件清单:
|
||||
-----------
|
||||
1. player_demo.html - HTML播放器页面
|
||||
2. chapter8_english_demo.wav - 音频文件 (2分12秒)
|
||||
3. chapter8_english_demo.srt - 字幕文件
|
||||
4. start_player.sh - 启动脚本
|
||||
|
||||
🎯 功能特点:
|
||||
----------
|
||||
✅ 音频播放控制 (播放/暂停)
|
||||
✅ 字幕实时高亮显示
|
||||
✅ 说话人头像状态同步 (谁说话谁亮)
|
||||
✅ 点击字幕跳转播放位置
|
||||
✅ 响应式设计 (支持手机/平板)
|
||||
|
||||
🚀 使用方法:
|
||||
----------
|
||||
方法1: 一键启动
|
||||
bash /root/tts/podcast_audios/start_player.sh
|
||||
|
||||
方法2: 手动启动
|
||||
cd /root/tts/podcast_audios
|
||||
python3 -m http.server 8080
|
||||
|
||||
访问地址:
|
||||
http://100.116.162.71:8080/player_demo.html
|
||||
|
||||
📝 演示内容:
|
||||
----------
|
||||
主题: 汤姆·克兰西的地缘政治预言
|
||||
对话角色:
|
||||
- S1 (主持人): 你的声音 (ben_guanquelou.wav)
|
||||
- S2 (嘉宾): Judy的声音 (judy_dalingtaohua_trim.wav)
|
||||
|
||||
技术亮点:
|
||||
- 零样本声音克隆
|
||||
- 2分钟+连续对话生成
|
||||
- 角色音色区分清晰
|
||||
- 自然对话节奏
|
||||
|
||||
📱 移动端访问:
|
||||
------------
|
||||
在手机浏览器输入相同地址,自动适配竖屏布局
|
||||
|
||||
🎧 音频验证:
|
||||
----------
|
||||
文件位置: /root/tts/podcast_audios/chapter8_english_demo.wav
|
||||
文件大小: 8.1MB
|
||||
音频时长: 2分12秒
|
||||
|
||||
命令验证:
|
||||
ffprobe chapter8_english_demo.wav 2>&1 | grep Duration
|
||||
|
||||
🔄 重新生成:
|
||||
----------
|
||||
如果需要重新生成音频:
|
||||
cd /root/tts/MOSS-TTSD
|
||||
python generate_chapter8_demo.py
|
||||
|
||||
💡 自定义对话:
|
||||
------------
|
||||
编辑文件: chapter8_english_script.txt
|
||||
运行: python generate_moss_ttsd_podcast.py chapter8_english_script.txt my_demo
|
||||
565
podcast_audios/project_chapter8_demo/config/player_demo.html
Normal file
565
podcast_audios/project_chapter8_demo/config/player_demo.html
Normal file
@@ -0,0 +1,565 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MOSS-TTSD Podcast Player - Chapter 8 Demo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
height: 90vh;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2em;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.avatars-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 60px;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.avatar-circle {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 4px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3em;
|
||||
margin: 0 auto 15px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar.s1 .avatar-circle {
|
||||
background: linear-gradient(135deg, rgba(135, 206, 235, 0.3), rgba(70, 130, 180, 0.5));
|
||||
}
|
||||
|
||||
.avatar.s2 .avatar-circle {
|
||||
background: linear-gradient(135deg, rgba(152, 251, 152, 0.3), rgba(50, 205, 50, 0.5));
|
||||
}
|
||||
|
||||
.avatar.active .avatar-circle {
|
||||
border-color: #ffd700;
|
||||
box-shadow: 0 0 30px rgba(255, 215, 0, 0.6);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.avatar.speaking::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
right: -10px;
|
||||
bottom: -10px;
|
||||
background: radial-gradient(circle, rgba(255, 215, 0, 0.3) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.8); opacity: 1; }
|
||||
50% { transform: scale(1.2); opacity: 0.5; }
|
||||
100% { transform: scale(0.8); opacity: 1; }
|
||||
}
|
||||
|
||||
.avatar-name {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.avatar-role {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.audio-player-container {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.control-btn.playing {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 30px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 20px;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.subtitle-line {
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-left: 4px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtitle-line:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.subtitle-line.active {
|
||||
border-left-color: #ffd700;
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.subtitle-line.s1 {
|
||||
border-left-color: #87ceeb;
|
||||
}
|
||||
|
||||
.subtitle-line.s2 {
|
||||
border-left-color: #98fb98;
|
||||
}
|
||||
|
||||
.subtitle-line.s1.active {
|
||||
border-left-color: #ffd700;
|
||||
}
|
||||
|
||||
.subtitle-line.s2.active {
|
||||
border-left-color: #ffd700;
|
||||
}
|
||||
|
||||
.speaker-label {
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.subtitle-line.s1 .speaker-label {
|
||||
color: #87ceeb;
|
||||
}
|
||||
|
||||
.subtitle-line.s2 .speaker-label {
|
||||
color: #98fb98;
|
||||
}
|
||||
|
||||
.subtitle-text {
|
||||
line-height: 1.5;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #ffd700;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.avatars-container {
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.avatar-circle {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="main-area">
|
||||
<div class="header">
|
||||
<h1>MOSS-TTSD Podcast Player</h1>
|
||||
<div class="subtitle">Chapter 8: Tom Clancy's Geopolitical Prophecy</div>
|
||||
</div>
|
||||
|
||||
<div class="avatars-container">
|
||||
<div class="avatar s1" id="avatar-s1">
|
||||
<div class="avatar-circle">🎤</div>
|
||||
<div class="avatar-name">Speaker 1</div>
|
||||
<div class="avatar-role">Host</div>
|
||||
</div>
|
||||
|
||||
<div class="avatar s2" id="avatar-s2">
|
||||
<div class="avatar-circle">🎙️</div>
|
||||
<div class="avatar-name">Speaker 2</div>
|
||||
<div class="avatar-role">Guest</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audio-player-container">
|
||||
<audio id="audio-player" class="audio-player" controls>
|
||||
<source src="chapter8_english_demo.wav" type="audio/wav">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<div class="controls">
|
||||
<button id="play-btn" class="control-btn">▶️ Play</button>
|
||||
<button id="sync-btn" class="control-btn">🔄 Sync</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<h2>Dialogue Script</h2>
|
||||
<div id="subtitle-container">
|
||||
<div class="loading">Loading subtitles...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class PodcastPlayer {
|
||||
constructor() {
|
||||
this.audio = document.getElementById('audio-player');
|
||||
this.playBtn = document.getElementById('play-btn');
|
||||
this.syncBtn = document.getElementById('sync-btn');
|
||||
this.subtitleContainer = document.getElementById('subtitle-container');
|
||||
this.avatarS1 = document.getElementById('avatar-s1');
|
||||
this.avatarS2 = document.getElementById('avatar-s2');
|
||||
|
||||
this.subtitles = [];
|
||||
this.currentSubtitleIndex = -1;
|
||||
this.isPlaying = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadSubtitles();
|
||||
this.setupEventListeners();
|
||||
this.renderSubtitles();
|
||||
}
|
||||
|
||||
async loadSubtitles() {
|
||||
try {
|
||||
const response = await fetch('chapter8_english_demo.srt');
|
||||
const srtText = await response.text();
|
||||
this.subtitles = this.parseSRT(srtText);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subtitles:', error);
|
||||
// Fallback: generate dummy subtitles for demo
|
||||
this.subtitles = this.generateDummySubtitles();
|
||||
}
|
||||
}
|
||||
|
||||
parseSRT(srtText) {
|
||||
const subtitles = [];
|
||||
const blocks = srtText.trim().split('\n\n');
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n').filter(line => line.trim()); // 过滤空行
|
||||
if (lines.length >= 3) {
|
||||
const timeLine = lines[1];
|
||||
const textLines = lines.slice(2);
|
||||
|
||||
// 找到第一个非空且包含[S1]或[S2]的行
|
||||
const speakerLine = textLines.find(line => line.includes('[S1]') || line.includes('[S2]'));
|
||||
if (!speakerLine) continue;
|
||||
|
||||
const [startTime, endTime] = timeLine.split(' --> ');
|
||||
const speaker = speakerLine.includes('[S1]') ? 's1' : 's2';
|
||||
const text = speakerLine.replace(/\[S[12]\]\s*/, '');
|
||||
|
||||
subtitles.push({
|
||||
start: this.parseTime(startTime),
|
||||
end: this.parseTime(endTime),
|
||||
speaker: speaker,
|
||||
text: text
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
generateDummySubtitles() {
|
||||
return [
|
||||
{ start: 0, end: 3.5, speaker: 's1', text: "Today we're discussing Tom Clancy. A thriller writer, yet called by the author the most underrated geopolitical analyst in American history." },
|
||||
{ start: 3.5, end: 5.2, speaker: 's2', text: "That's a bold claim. Tell me more." },
|
||||
{ start: 5.2, end: 9.8, speaker: 's1', text: "His 2000 novel 'The Bear and the Dragon' predicted the 2022 Russia-Ukraine war and the China-Russia 'no-limits' alliance, 22 years in advance." },
|
||||
{ start: 9.8, end: 12.5, speaker: 's1', text: "How did a guy writing airport novels in Maryland see more clearly than the entire CIA?" },
|
||||
{ start: 12.5, end: 16.2, speaker: 's2', text: "This isn't just prediction, it's simulation. He built Soviet naval strategy models so accurate that the Pentagon invited him to wargame with generals." },
|
||||
{ start: 16.2, end: 19.0, speaker: 's2', text: "He understood one key thing: war is a system, not an event." },
|
||||
{ start: 19.0, end: 23.5, speaker: 's1', text: "'The Hunt for Red October' is a primer on submarine acoustics. 'Clear and Present Danger' explains drug cartels and congressional oversight." },
|
||||
{ start: 23.5, end: 26.0, speaker: 's1', text: "But 'The Bear and the Dragon' is truly scary." },
|
||||
{ start: 26.0, end: 30.5, speaker: 's2', text: "In the book, China and Russia form a military alliance, invade Alaska, catch America off-guard, paralyze NATO." },
|
||||
{ start: 30.5, end: 34.8, speaker: 's2', text: "But here's the brilliant part: the war doesn't end in nuclear apocalypse. It ends in ceasefire and a new Cold War stalemate." },
|
||||
{ start: 34.8, end: 39.2, speaker: 's1', text: "In 2022, China and Russia didn't invade Alaska, but they did form a 'no-limits' alliance that caught the West off-guard." },
|
||||
{ start: 39.2, end: 41.0, speaker: 's1', text: "Why was Clancy right?" },
|
||||
{ start: 41.0, end: 45.5, speaker: 's2', text: "Because he understood the mathematics of power. When China's economy became 10 times Russia's, the math changed." },
|
||||
{ start: 45.5, end: 49.0, speaker: 's2', text: "Russia could no longer be a peer player, it had to become a junior partner. This isn't ideology, it's arithmetic." },
|
||||
{ start: 49.0, end: 54.5, speaker: 's1', text: "The scariest part: when Clancy wrote the book, China's GDP was still smaller than Italy's. Everyone thought he was crazy." },
|
||||
{ start: 54.5, end: 58.0, speaker: 's1', text: "But he saw the trajectory. He saw that the 21st century would be Asian." },
|
||||
{ start: 58.0, end: 62.5, speaker: 's2', text: "China had two choices: dominate its neighbors or merge with them. China chose to merge. Russia had no choice." },
|
||||
{ start: 62.5, end: 66.8, speaker: 's2', text: "That's why Clancy isn't a novelist, he's an analyst. He read the same data as everyone else, but he knew how to read it for blood." }
|
||||
];
|
||||
}
|
||||
|
||||
parseTime(timeStr) {
|
||||
const [time, ms] = timeStr.split(',');
|
||||
const [hours, minutes, seconds] = time.split(':');
|
||||
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds) + parseInt(ms) / 1000;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.audio.addEventListener('timeupdate', () => {
|
||||
this.updateSubtitle();
|
||||
});
|
||||
|
||||
this.audio.addEventListener('play', () => {
|
||||
this.isPlaying = true;
|
||||
this.playBtn.textContent = '⏸️ Pause';
|
||||
this.playBtn.classList.add('playing');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('pause', () => {
|
||||
this.isPlaying = false;
|
||||
this.playBtn.textContent = '▶️ Play';
|
||||
this.playBtn.classList.remove('playing');
|
||||
});
|
||||
|
||||
this.playBtn.addEventListener('click', () => {
|
||||
if (this.audio.paused) {
|
||||
this.audio.play();
|
||||
} else {
|
||||
this.audio.pause();
|
||||
}
|
||||
});
|
||||
|
||||
this.syncBtn.addEventListener('click', () => {
|
||||
this.syncSubtitles();
|
||||
});
|
||||
}
|
||||
|
||||
updateSubtitle() {
|
||||
const currentTime = this.audio.currentTime;
|
||||
const subtitleIndex = this.findSubtitleIndex(currentTime);
|
||||
|
||||
if (subtitleIndex !== this.currentSubtitleIndex) {
|
||||
console.log('Subtitle changed:', subtitleIndex, 'at time:', currentTime);
|
||||
this.currentSubtitleIndex = subtitleIndex;
|
||||
this.highlightSubtitle(subtitleIndex);
|
||||
this.updateAvatar(subtitleIndex >= 0 ? this.subtitles[subtitleIndex].speaker : null);
|
||||
}
|
||||
}
|
||||
|
||||
findSubtitleIndex(time) {
|
||||
for (let i = 0; i < this.subtitles.length; i++) {
|
||||
if (time >= this.subtitles[i].start && time < this.subtitles[i].end) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
highlightSubtitle(index) {
|
||||
// Remove previous active class
|
||||
const prevActive = document.querySelector('.subtitle-line.active');
|
||||
if (prevActive) {
|
||||
prevActive.classList.remove('active');
|
||||
}
|
||||
|
||||
// Add active class to current subtitle
|
||||
if (index >= 0) {
|
||||
const element = document.querySelector(`[data-subtitle-index="${index}"]`);
|
||||
if (element) {
|
||||
console.log('Highlighting subtitle:', index, element);
|
||||
element.classList.add('active');
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
console.error('Subtitle element not found for index:', index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateAvatar(speaker) {
|
||||
// Reset avatars
|
||||
this.avatarS1.classList.remove('active', 'speaking');
|
||||
this.avatarS2.classList.remove('active', 'speaking');
|
||||
|
||||
// Activate current speaker
|
||||
if (speaker === 's1') {
|
||||
console.log('Activating S1 avatar');
|
||||
this.avatarS1.classList.add('active', 'speaking');
|
||||
} else if (speaker === 's2') {
|
||||
console.log('Activating S2 avatar');
|
||||
this.avatarS2.classList.add('active', 'speaking');
|
||||
} else {
|
||||
console.log('No speaker active');
|
||||
}
|
||||
}
|
||||
|
||||
renderSubtitles() {
|
||||
this.subtitleContainer.innerHTML = '';
|
||||
|
||||
this.subtitles.forEach((subtitle, index) => {
|
||||
const lineElement = document.createElement('div');
|
||||
lineElement.className = `subtitle-line ${subtitle.speaker}`;
|
||||
lineElement.setAttribute('data-subtitle-index', index);
|
||||
|
||||
lineElement.innerHTML = `
|
||||
<div class="speaker-label">[${subtitle.speaker.toUpperCase()}]</div>
|
||||
<div class="subtitle-text">${subtitle.text}</div>
|
||||
<div class="time-display">${this.formatTime(subtitle.start)} - ${this.formatTime(subtitle.end)}</div>
|
||||
`;
|
||||
|
||||
lineElement.addEventListener('click', () => {
|
||||
this.audio.currentTime = subtitle.start;
|
||||
this.audio.play();
|
||||
});
|
||||
|
||||
this.subtitleContainer.appendChild(lineElement);
|
||||
});
|
||||
}
|
||||
|
||||
formatTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
syncSubtitles() {
|
||||
// Recalculate subtitle timings based on audio duration
|
||||
const audioDuration = this.audio.duration;
|
||||
if (audioDuration && this.subtitles.length > 0) {
|
||||
const subtitleDuration = audioDuration / this.subtitles.length;
|
||||
this.subtitles.forEach((subtitle, index) => {
|
||||
subtitle.start = index * subtitleDuration;
|
||||
subtitle.end = (index + 1) * subtitleDuration;
|
||||
});
|
||||
console.log('Subtitles synced to audio duration:', audioDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize player when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new PodcastPlayer();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
15
podcast_audios/project_chapter8_demo/config/start_player.sh
Normal file
15
podcast_audios/project_chapter8_demo/config/start_player.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🎙️ MOSS-TTSD Podcast Player Demo"
|
||||
echo "================================"
|
||||
echo ""
|
||||
echo "📁 文件位置: /root/tts/podcast_audios/"
|
||||
echo "📄 HTML文件: player_demo.html"
|
||||
echo "🎵 音频文件: chapter8_english_demo.wav"
|
||||
echo "📝 字幕文件: chapter8_english_demo.srt"
|
||||
echo ""
|
||||
echo "🚀 启动HTTP服务器..."
|
||||
echo ""
|
||||
|
||||
cd /root/tts/podcast_audios
|
||||
python3 -m http.server 8080
|
||||
85
podcast_audios/project_chapter8_demo/config/test_player.html
Normal file
85
podcast_audios/project_chapter8_demo/config/test_player.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Test Player</title>
|
||||
<style>
|
||||
body { font-family: Arial; padding: 20px; }
|
||||
.avatar { width: 100px; height: 100px; border: 3px solid gray; border-radius: 50%; display: inline-block; margin: 20px; text-align: center; line-height: 100px; }
|
||||
.avatar.active { border-color: gold; background: yellow; }
|
||||
.subtitle { padding: 10px; margin: 5px; background: #f0f0f0; }
|
||||
.subtitle.active { background: yellow; }
|
||||
.s1 { border-left: 5px solid blue; }
|
||||
.s2 { border-left: 5px solid green; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Player</h1>
|
||||
|
||||
<div>
|
||||
<div class="avatar s1" id="avatar-s1">S1</div>
|
||||
<div class="avatar s2" id="avatar-s2">S2</div>
|
||||
</div>
|
||||
|
||||
<audio id="audio" controls>
|
||||
<source src="chapter8_english_demo.wav" type="audio/wav">
|
||||
</audio>
|
||||
|
||||
<div id="subtitle-container"></div>
|
||||
|
||||
<div id="debug" style="margin-top: 20px; padding: 10px; background: #eee;"></div>
|
||||
|
||||
<script>
|
||||
// 测试字幕数据
|
||||
const testSubtitles = [
|
||||
{ start: 0, end: 3, speaker: 's1', text: 'First subtitle from S1' },
|
||||
{ start: 3, end: 6, speaker: 's2', text: 'Second subtitle from S2' },
|
||||
{ start: 6, end: 9, speaker: 's1', text: 'Third subtitle from S1' }
|
||||
];
|
||||
|
||||
const audio = document.getElementById('audio');
|
||||
const container = document.getElementById('subtitle-container');
|
||||
const debug = document.getElementById('debug');
|
||||
let currentIndex = -1;
|
||||
|
||||
// 渲染字幕
|
||||
testSubtitles.forEach((sub, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = `subtitle ${sub.speaker}`;
|
||||
div.textContent = `[${sub.speaker}] ${sub.text}`;
|
||||
div.dataset.index = index;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
// 更新时间
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
const time = audio.currentTime;
|
||||
const newIndex = testSubtitles.findIndex(sub => time >= sub.start && time < sub.end);
|
||||
|
||||
if (newIndex !== currentIndex) {
|
||||
currentIndex = newIndex;
|
||||
|
||||
// 清除之前的高亮
|
||||
document.querySelectorAll('.subtitle').forEach(s => s.classList.remove('active'));
|
||||
document.querySelectorAll('.avatar').forEach(a => a.classList.remove('active'));
|
||||
|
||||
if (newIndex >= 0) {
|
||||
// 高亮新的字幕和头像
|
||||
const subtitleEl = document.querySelector(`[data-index="${newIndex}"]`);
|
||||
if (subtitleEl) subtitleEl.classList.add('active');
|
||||
|
||||
const speaker = testSubtitles[newIndex].speaker;
|
||||
const avatarEl = document.getElementById(`avatar-${speaker}`);
|
||||
if (avatarEl) avatarEl.classList.add('active');
|
||||
|
||||
debug.innerHTML = `Time: ${time.toFixed(2)}s, Index: ${newIndex}, Speaker: ${speaker}`;
|
||||
} else {
|
||||
debug.innerHTML = `Time: ${time.toFixed(2)}s, No subtitle`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debug.innerHTML = 'Ready. Press play to test.';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user