Преглед на файлове

fix: 模式切换按钮独立行 + 轮询超时600s

- create.vue & interactive.vue: 模式切换按钮从右上角移到标题下方独立一行,蓝底白字全宽
- interactive.vue: Plan和Outline轮询超时从180s→600s
- docs/ai-interaction-spec.md: 同步更新超时规范
MyFramework User преди 3 месеца
родител
ревизия
4bfcc60b13

+ 2 - 2
docs/ai-interaction-spec.md

@@ -17,7 +17,7 @@ GET  /api/xxx/status   →  轮询返回 { genStage, result?, error? }
 |----------|------|------|
 | POST 启动接口 | 10s | 仅做参数校验 + 投递异步任务 |
 | GET 轮询接口 | 5s | 查询数据库状态,不涉及 AI |
-| 前端整体等待上限 | 180s | 超时后提示用户重试 |
+| 前端整体等待上限 | 600s | 超时后提示用户重试 |
 
 ## 状态流转
 
@@ -44,7 +44,7 @@ generating_content → completed     (内容生成完成)
 - POST 后立即进入 `setInterval` 轮询,间隔 3s
 - 收到 `xxx_ready` 后停止轮询,渲染结果
 - 收到 `xxx_failed` 后显示错误,允许重试
-- 超过 180s 整体上限后提示超时
+- 超过 600s 整体上限后提示超时
 
 ## 禁止事项
 

+ 9 - 10
my-uniapp-vue3/src/pages/book-generator/create.vue

@@ -7,9 +7,10 @@
           <text class="back-icon">←</text>
         </view>
         <text class="page-title">创建书籍</text>
-        <view class="nav-right">
-          <text class="mode-switch" @click="switchToInteractive">交互模式</text>
-        </view>
+      </view>
+      <!-- 模式切换独立行 -->
+      <view class="mode-bar">
+        <text class="mode-switch" @click="switchToInteractive">切换到交互模式</text>
       </view>
     </view>
 
@@ -766,15 +767,12 @@ onMounted(() => {
 .nav-content {
   display: flex;
   align-items: center;
-  justify-content: space-between;
   height: 88rpx;
   padding: 0 32rpx;
   padding-top: env(safe-area-inset-top);
 }
 
 .nav-left { width: 60rpx; flex-shrink: 0; }
-.nav-right { display: flex; align-items: center; flex-shrink: 0; }
-.mode-switch { font-size: 24rpx; color: #4f46e5; padding: 8rpx 20rpx; border: 1rpx solid #4f46e5; border-radius: 20rpx; white-space: nowrap; flex-shrink: 0; }
 .back-icon { font-size: 40rpx; color: #1f2937; }
 .page-title {
   font-size: 32rpx;
@@ -782,13 +780,14 @@ onMounted(() => {
   color: #1f2937;
   flex: 1;
   text-align: center;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
 }
 
+/* 模式切换独立行 */
+.mode-bar { padding: 0 32rpx 16rpx 32rpx; }
+.mode-switch { display: flex; align-items: center; justify-content: center; width: 100%; height: 72rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #fff; border-radius: 12rpx; font-size: 26rpx; font-weight: 600; }
+
 .main-content {
-  padding-top: calc(88rpx + env(safe-area-inset-top));
+  padding-top: calc(88rpx + 16rpx + 72rpx + 16rpx + env(safe-area-inset-top));
 }
 
 .card {

+ 14 - 11
my-uniapp-vue3/src/pages/book-generator/interactive.vue

@@ -7,9 +7,10 @@
           <text class="back-icon">←</text>
         </view>
         <text class="page-title">交互式创建</text>
-        <view class="nav-right">
-          <text class="mode-switch" @click="switchToSimple">一键模式</text>
-        </view>
+      </view>
+      <!-- 模式切换独立行 -->
+      <view class="mode-bar">
+        <text class="mode-switch" @click="switchToSimple">切换到一键模式</text>
       </view>
     </view>
 
@@ -624,7 +625,7 @@ async function doGeneratePlan(feedback?: string) {
         }
 
         // 超时检查
-        if (Date.now() - startTime > 180000) {
+        if (Date.now() - startTime > 600000) {
           stopPlanPolling();
           planError.value = 'AI 规划超时,请重新分析';
           planLoading.value = false;
@@ -739,7 +740,7 @@ async function doGenerateOutline() {
         }
 
         // 超时检查
-        if (Date.now() - startTime > 180000) {
+        if (Date.now() - startTime > 600000) {
           stopOutlinePolling();
           outlineError.value = '大纲生成超时,请重新生成';
           outlineLoading.value = false;
@@ -853,15 +854,17 @@ onMounted(() => {
 .page { min-height: 100vh; background: #f9fafb; }
 
 .nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
-.nav-content { display: flex; align-items: center; justify-content: space-between; height: 88rpx; padding: 0 32rpx; padding-top: env(safe-area-inset-top); }
+.nav-content { display: flex; align-items: center; height: 88rpx; padding: 0 32rpx; padding-top: env(safe-area-inset-top); }
 .nav-left { width: 60rpx; flex-shrink: 0; }
-.nav-right { display: flex; align-items: center; flex-shrink: 0; }
+.page-title { font-size: 32rpx; font-weight: 600; color: #1f2937; text-align: center; flex: 1; }
 .back-icon { font-size: 40rpx; color: #1f2937; }
-.page-title { font-size: 32rpx; font-weight: 600; color: #1f2937; text-align: center; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.mode-switch { font-size: 24rpx; color: #4f46e5; padding: 8rpx 20rpx; border: 1rpx solid #4f46e5; border-radius: 20rpx; white-space: nowrap; flex-shrink: 0; }
 
-/* Steps Bar */
-.steps-bar { display: flex; align-items: center; justify-content: center; padding: 24rpx 32rpx; padding-top: calc(88rpx + env(safe-area-inset-top) + 16rpx); background: #fff; position: relative; gap: 60rpx; }
+/* 模式切换独立行 */
+.mode-bar { padding: 0 32rpx 16rpx 32rpx; }
+.mode-switch { display: flex; align-items: center; justify-content: center; width: 100%; height: 72rpx; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: #fff; border-radius: 12rpx; font-size: 26rpx; font-weight: 600; }
+
+/* 覆盖 steps-bar 顶部间距 — nav-bar 现在更高了 */
+.steps-bar { display: flex; align-items: center; justify-content: center; padding: 24rpx 32rpx; padding-top: calc(88rpx + 16rpx + 72rpx + 16rpx + env(safe-area-inset-top) + 16rpx); background: #fff; position: relative; gap: 60rpx; }
 .step-dot { display: flex; flex-direction: column; align-items: center; gap: 8rpx; z-index: 1; }
 .dot-num { width: 48rpx; height: 48rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24rpx; font-weight: 600; background: #e5e7eb; color: #9ca3af; transition: all .3s; }
 .step-dot.active .dot-num { background: #4f46e5; color: #fff; }

+ 53 - 25
my-uniapp-vue3/src/pages/player/index.vue

@@ -254,36 +254,64 @@ function parseLrc(lrcText: string): { text: string; start: number; end: number }
   return timeline;
 }
 
-// 生成歌词时间线(按句子估算
+// 生成歌词时间线(按 Markdown 段落分段,与后端 generateLrc 降级算法一致
 function generateLyricsTimeline(text: string, totalDuration: number): { text: string; start: number; end: number }[] {
-  if (!text) return [];
+  if (!text || totalDuration <= 0) return [];
+
+  // 按 Markdown 结构分段
+  const sections: string[] = [];
+  const h1Parts = text.split(/(?=^# )/m);
+  for (const h1Part of h1Parts) {
+    if (!h1Part.trim()) continue;
+    const h2Parts = h1Part.split(/(?=^## )/m);
+    for (const h2Part of h2Parts) {
+      const trimmed = h2Part.trim();
+      if (!trimmed) continue;
+      const isHeading = /^#{1,2}\s/.test(trimmed);
+      if (isHeading) {
+        sections.push(trimmed);
+      } else {
+        const paras = trimmed.split(/\n{2,}/).filter(p => p.trim());
+        for (const para of paras) {
+          sections.push(para.trim());
+        }
+      }
+    }
+  }
+  // 降级:按句号分段
+  if (sections.length <= 1) {
+    const sentences = text.split(/(?<=[。!?;])\s*/).filter(s => s.trim());
+    sections.length = 0;
+    sections.push(...sentences);
+  }
+
+  // 权重分配:标题 0.3x,正文 1.0x
+  const weights = sections.map(s => /^#{1,2}\s/.test(s) ? 0.3 : 1.0);
+  const weightedChars = sections.map((s, i) => {
+    const chars = (s.match(/[一-龥a-zA-Z0-9]/g) || []).length;
+    return chars * weights[i];
+  });
+  const totalWeighted = weightedChars.reduce((a, b) => a + b, 0);
+  if (totalWeighted === 0) return [];
 
-  // 按标点分割句子
-  const sentences = text.split(/(?<=[。!?;\n])/);
   const timeline: { text: string; start: number; end: number }[] = [];
   let currentTime = 0;
-
-  // 估算总字数(中文+英文+数字)
-  const totalChars = (text.match(/[一-龥a-zA-Z0-9]/g) || []).length;
-  const charsPerSecond = totalChars / totalDuration || 4.5;
-
-  for (const sentence of sentences) {
-    const trimmed = sentence.trim();
-    if (!trimmed) continue;
-
-    // 估算句子字数和时长
-    const chars = (trimmed.match(/[一-龥a-zA-Z0-9]/g) || []).length;
-    const sentenceDuration = chars / charsPerSecond;
-
-    timeline.push({
-      text: trimmed,
-      start: currentTime,
-      end: currentTime + sentenceDuration,
-    });
-
-    currentTime += sentenceDuration;
+  for (let i = 0; i < sections.length; i++) {
+    const ratio = weightedChars[i] / totalWeighted;
+    const segDuration = ratio * totalDuration;
+    const start = currentTime;
+    const end = currentTime + segDuration;
+    timeline.push({ text: sections[i], start, end });
+    currentTime += segDuration;
+    // 标题后加停顿
+    if (i < sections.length - 1 && /^#{1,2}\s/.test(sections[i])) {
+      currentTime = Math.min(currentTime + 0.5, totalDuration);
+    }
+  }
+  // 修正最后一段
+  if (timeline.length > 0) {
+    timeline[timeline.length - 1].end = totalDuration;
   }
-
   return timeline;
 }
 

+ 132 - 24
server/src/modules/tts/tts.service.ts

@@ -448,8 +448,9 @@ async function processAudioGeneration(
       // 最终音频 URL:统一使用 storageService 处理后的 URL
       const finalAudioUrl = audioUrl;
 
-      // 生成 LRC 格式歌词时间轴
-      const lrcLyrics = generateLrc(text, duration);
+      // 生成 LRC 格式歌词时间轴(优先使用 FFmpeg 精确停顿检测)
+      const localAudioPath = path.join(audioDir, 'output.mp3');
+      const lrcLyrics = generateLrc(text, duration, localAudioPath);
       if (lrcLyrics) {
         console.log(`🎵 LRC 歌词已生成: ${lrcLyrics.split('\n').length} 句`);
       }
@@ -602,36 +603,143 @@ export function getVoices(): Voice[] {
   return VOICES;
 }
 
-// 生成 LRC 格式歌词时间轴
-export function generateLrc(text: string, duration: number): string {
-  if (!text || duration <= 0) return '';
+// ============ FFmpeg 静音检测 + 精确 LRC 生成 ============
 
-  // 按标点分割句子
-  const sentences = text.split(/(?<=[。!?;\n])/);
-  const lines: string[] = [];
-  let currentTime = 0;
+/**
+ * 用 FFmpeg 检测音频中的静音间隙,返回真实的停顿时间点(秒)
+ * silencedetect 参数:-30dB 阈值,0.3s 最小静音时长
+ */
+export function detectAudioSilence(audioPath: string): number[] {
+  try {
+    const result = execSync(
+      `ffmpeg -i "${audioPath}" -af silencedetect=n=-30dB:d=0.3 -f null - 2>&1`,
+      { timeout: 30000, encoding: 'utf-8' }
+    );
+    const silenceEnds: number[] = [];
+    const regex = /silence_end:\s*([\d.]+)/g;
+    let match;
+    while ((match = regex.exec(result)) !== null) {
+      silenceEnds.push(parseFloat(match[1]));
+    }
+    console.log(`🔇 FFmpeg 检测到 ${silenceEnds.length} 个静音点`);
+    return silenceEnds;
+  } catch (err: any) {
+    console.warn(`⚠️ FFmpeg 静音检测失败,降级到估算模式:`, err.message);
+    return [];
+  }
+}
+
+/** 按 Markdown 结构分段(优先级:# 标题 > ## 小节 > 空行分段 > 。!?断句) */
+function splitByMarkdownSections(text: string): string[] {
+  const sections: string[] = [];
+
+  // 先按一级标题分割
+  const h1Parts = text.split(/(?=^# )/m);
+  for (const h1Part of h1Parts) {
+    if (!h1Part.trim()) continue;
+    // 再按二级标题分割
+    const h2Parts = h1Part.split(/(?=^## )/m);
+    for (const h2Part of h2Parts) {
+      const trimmed = h2Part.trim();
+      if (!trimmed) continue;
+      // 标题行占比少,作为一个独立段落
+      const isHeading = /^#{1,2}\s/.test(trimmed);
+      if (isHeading) {
+        sections.push(trimmed);
+      } else {
+        // 正文段落:按空行分割
+        const paras = trimmed.split(/\n{2,}/).filter(p => p.trim());
+        for (const para of paras) {
+          sections.push(para.trim());
+        }
+      }
+    }
+  }
+  // 如果没有 markdown 结构,退回到按句号分段
+  if (sections.length <= 1) {
+    return text.split(/(?<=[。!?;])\s*/).filter(s => s.trim());
+  }
+  return sections;
+}
+
+/** 格式化秒数为 [MM:SS.XX] */
+function formatLrcTimestamp(seconds: number): string {
+  const m = Math.floor(seconds / 60);
+  const s = seconds % 60;
+  const cs = Math.round((s - Math.floor(s)) * 100);
+  const sec = Math.floor(s);
+  return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${cs.toString().padStart(2, '0')}`;
+}
 
-  // 估算总字数(中文+英文+数字)
-  const totalChars = (text.match(/[一-龥a-zA-Z0-9]/g) || []).length;
-  const charsPerSecond = totalChars / duration || 4.5;
+/**
+ * 基于真实停顿点 + Markdown 段落 生成精确 LRC 歌词
+ * 优先使用 FFmpeg 静音检测,失败退回到智能分段估算
+ */
+export function generateLrc(text: string, duration: number, audioPath?: string): string {
+  if (!text || duration <= 0) return '';
 
-  for (const sentence of sentences) {
-    const trimmed = sentence.trim();
-    if (!trimmed) continue;
+  const sections = splitByMarkdownSections(text);
+  if (sections.length === 0) return '';
+
+  // 尝试 FFmpeg 静音检测获取真实停顿点
+  let silencePoints: number[] = [];
+  if (audioPath && fs.existsSync(audioPath)) {
+    silencePoints = detectAudioSilence(audioPath);
+  }
 
-    // 估算句子时长
-    const chars = (trimmed.match(/[一-龥a-zA-Z0-9]/g) || []).length;
-    const sentenceDuration = chars / charsPerSecond;
+  // 如果有足够多的真实停顿点(至少比段落数少1),直接映射
+  if (silencePoints.length >= sections.length - 1) {
+    console.log(`✅ 使用 FFmpeg 精确停顿点生成 LRC (${sections.length} 个段落)`);
+    return buildLrcFromSilencePoints(sections, silencePoints, duration);
+  }
 
-    // 格式化时间戳 [MM:SS.XX]
-    const minutes = Math.floor(currentTime / 60);
-    const seconds = currentTime % 60;
-    const timestamp = `${minutes.toString().padStart(2, '0')}:${seconds.toFixed(2).padStart(5, '0')}`;
+  // 降级:智能分段估算(标题停顿长、正文停顿短)
+  console.log(`📝 FFmpeg 数据不足,使用智能估算生成 LRC (${sections.length} 个段落)`);
+  return buildLrcByEstimation(sections, duration);
+}
 
-    lines.push(`[${timestamp}] ${trimmed}`);
-    currentTime += sentenceDuration;
+/** 用真实停顿点构建 LRC */
+function buildLrcFromSilencePoints(sections: string[], silencePoints: number[], totalDuration: number): string {
+  const lines: string[] = [];
+  for (let i = 0; i < sections.length; i++) {
+    const startTime = i === 0 ? 0 : silencePoints[i - 1];
+    const endTime = i < silencePoints.length ? silencePoints[i] : totalDuration;
+    const correctedStart = Math.min(startTime, totalDuration - 0.5);
+    lines.push(`[${formatLrcTimestamp(correctedStart)}] ${sections[i]}`);
   }
+  return lines.join('\n');
+}
 
+/** 智能估算构建 LRC(降级方案) */
+function buildLrcByEstimation(sections: string[], totalDuration: number): string {
+  const lines: string[] = [];
+
+  // 分配时长:标题权重 0.3,正文权重 1.0,标题间加 0.5s 停顿
+  const weights = sections.map((s) => {
+    const isHeading = /^#{1,2}\s/.test(s);
+    return isHeading ? 0.3 : 1.0;
+  });
+
+  // 按字符加权分配
+  const weightedChars = sections.map((s, i) => {
+    const chars = (s.match(/[一-龥a-zA-Z0-9]/g) || []).length;
+    return chars * weights[i];
+  });
+  const totalWeighted = weightedChars.reduce((a, b) => a + b, 0);
+  if (totalWeighted === 0) return '';
+
+  let currentTime = 0;
+  for (let i = 0; i < sections.length; i++) {
+    const ratio = weightedChars[i] / totalWeighted;
+    const segmentDuration = ratio * totalDuration;
+
+    lines.push(`[${formatLrcTimestamp(currentTime)}] ${sections[i]}`);
+    currentTime += segmentDuration;
+    // 标题后加小停顿
+    if (i < sections.length - 1 && /^#{1,2}\s/.test(sections[i])) {
+      currentTime = Math.min(currentTime + 0.5, totalDuration);
+    }
+  }
   return lines.join('\n');
 }