938ef0b8cc
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 51s
CI/CD Pipeline / Integration Tests (push) Successful in 3m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m37s
CI/CD Pipeline / Validate - Style (push) Successful in 4m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m29s
CI/CD Pipeline / Validate - Security (push) Successful in 8m25s
CI/CD Pipeline / Unit Tests (push) Successful in 8m59s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 18m30s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m7s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m42s
CI/CD Pipeline / CI Gate (push) Failing after 108h33m30s
CI/CD Pipeline / Build Production API Image (push) Failing after 108h33m30s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 108h41m37s
CI/CD Pipeline / PR Build Web Image (push) Failing after 108h42m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 108h33m12s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 108h42m18s
CI/CD Pipeline / PR Build API Image (push) Failing after 108h42m18s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 108h41m19s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 108h41m19s
CI/CD Pipeline / Canary Release to Production (push) Failing after 108h18m59s
CI/CD Pipeline / Deploy Production (push) Failing after 108h33m9s
CI/CD Pipeline / Build Production Web Image (push) Failing after 108h33m12s
CI/CD Pipeline / Frontend Lint (push) Failing after 108h42m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 108h42m19s
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com> Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
172 lines
7.0 KiB
Python
172 lines
7.0 KiB
Python
"""Tests for sentence timing functions in lipsync_tts."""
|
|
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from apps.api.app.tasks.lipsync_tts import (
|
|
_compute_sentence_timings,
|
|
_estimate_sentence_timings_by_chars,
|
|
_split_script_into_sentences,
|
|
)
|
|
|
|
|
|
class TestSplitScriptIntoSentences(unittest.TestCase):
|
|
"""Tests for _split_script_into_sentences."""
|
|
|
|
def test_empty_string(self):
|
|
self.assertEqual(_split_script_into_sentences(""), [])
|
|
|
|
def test_none(self):
|
|
self.assertEqual(_split_script_into_sentences(None), [])
|
|
|
|
def test_whitespace_only(self):
|
|
self.assertEqual(_split_script_into_sentences(" \n "), [])
|
|
|
|
def test_single_sentence(self):
|
|
self.assertEqual(_split_script_into_sentences("你好世界。"), ["你好世界"])
|
|
|
|
def test_multiple_sentences_chinese(self):
|
|
result = _split_script_into_sentences("第一句。第二句!第三句?")
|
|
self.assertEqual(result, ["第一句", "第二句", "第三句"])
|
|
|
|
def test_english_punctuation(self):
|
|
result = _split_script_into_sentences("Hello World! How are you?")
|
|
self.assertEqual(result, ["Hello World", "How are you"])
|
|
|
|
def test_semicolons(self):
|
|
result = _split_script_into_sentences("第一部分;第二部分;第三部分")
|
|
self.assertEqual(result, ["第一部分", "第二部分", "第三部分"])
|
|
|
|
def test_newlines(self):
|
|
result = _split_script_into_sentences("第一行\n第二行\n第三行")
|
|
self.assertEqual(result, ["第一行", "第二行", "第三行"])
|
|
|
|
def test_no_trailing_punctuation(self):
|
|
result = _split_script_into_sentences("没有标点的句子")
|
|
self.assertEqual(result, ["没有标点的句子"])
|
|
|
|
|
|
class TestEstimateSentenceTimingsByChars(unittest.TestCase):
|
|
"""Tests for _estimate_sentence_timings_by_chars."""
|
|
|
|
def test_empty_sentences(self):
|
|
self.assertEqual(_estimate_sentence_timings_by_chars([], 10.0), [])
|
|
|
|
def test_zero_duration(self):
|
|
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], 0), [])
|
|
|
|
def test_negative_duration(self):
|
|
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], -5.0), [])
|
|
|
|
def test_single_sentence(self):
|
|
result = _estimate_sentence_timings_by_chars(["hello"], 10.0)
|
|
self.assertEqual(len(result), 1)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 10.0)
|
|
|
|
def test_two_equal_sentences(self):
|
|
result = _estimate_sentence_timings_by_chars(["你好", "世界"], 10.0)
|
|
self.assertEqual(len(result), 2)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 5.0)
|
|
self.assertAlmostEqual(result[1]["start_time"], 5.0)
|
|
self.assertAlmostEqual(result[1]["end_time"], 10.0)
|
|
|
|
def test_unequal_char_distribution(self):
|
|
result = _estimate_sentence_timings_by_chars(["ABCD", "EF"], 9.0)
|
|
self.assertEqual(len(result), 2)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 6.0) # 4/6 * 9 = 6
|
|
self.assertAlmostEqual(result[1]["start_time"], 6.0)
|
|
self.assertAlmostEqual(result[1]["end_time"], 9.0)
|
|
|
|
def test_timing_structure(self):
|
|
result = _estimate_sentence_timings_by_chars(["句子一", "句子二"], 6.0)
|
|
for item in result:
|
|
self.assertIn("index", item)
|
|
self.assertIn("text", item)
|
|
self.assertIn("start_time", item)
|
|
self.assertIn("end_time", item)
|
|
|
|
|
|
class TestComputeSentenceTimings(unittest.TestCase):
|
|
"""Tests for _compute_sentence_timings."""
|
|
|
|
def test_empty_script_returns_empty(self):
|
|
self.assertEqual(_compute_sentence_timings(b"fake_audio", "", 10.0), [])
|
|
|
|
def test_none_script_returns_empty(self):
|
|
self.assertEqual(_compute_sentence_timings(b"fake_audio", None, 10.0), [])
|
|
|
|
@patch("os.unlink")
|
|
@patch.object(tempfile, "NamedTemporaryFile")
|
|
@patch.object(subprocess, "run")
|
|
def test_silence_detection_insufficient_fallback(self, mock_run, mock_tmpfile, mock_unlink):
|
|
"""When silence detection finds too few points, fallback to char estimation."""
|
|
mock_run.return_value = MagicMock(stderr="", returncode=0)
|
|
mock_tmp = MagicMock()
|
|
mock_tmp.name = "/tmp/fake.mp3"
|
|
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
|
|
mock_tmp.__exit__ = MagicMock(return_value=False)
|
|
mock_tmpfile.return_value = mock_tmp
|
|
|
|
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
|
|
|
|
# Should fallback to char estimation with 3 sentences
|
|
self.assertEqual(len(result), 3)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
|
|
@patch("os.unlink")
|
|
@patch.object(tempfile, "NamedTemporaryFile")
|
|
@patch.object(subprocess, "run")
|
|
def test_silence_detection_with_enough_points(self, mock_run, mock_tmpfile, mock_unlink):
|
|
"""When silence detection finds enough points, use them for boundaries."""
|
|
mock_run.return_value = MagicMock(
|
|
stderr="[silencedetect] silence_end: 3.5 | silence_duration: 0.4\n"
|
|
"[silencedetect] silence_end: 7.0 | silence_duration: 0.3\n",
|
|
returncode=0,
|
|
)
|
|
mock_tmp = MagicMock()
|
|
mock_tmp.name = "/tmp/fake.mp3"
|
|
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
|
|
mock_tmp.__exit__ = MagicMock(return_value=False)
|
|
mock_tmpfile.return_value = mock_tmp
|
|
|
|
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
|
|
|
|
self.assertEqual(len(result), 3)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 3.5)
|
|
self.assertAlmostEqual(result[1]["start_time"], 3.5)
|
|
self.assertAlmostEqual(result[1]["end_time"], 7.0)
|
|
self.assertAlmostEqual(result[2]["start_time"], 7.0)
|
|
self.assertAlmostEqual(result[2]["end_time"], 10.0)
|
|
|
|
@patch("os.unlink")
|
|
@patch.object(tempfile, "NamedTemporaryFile")
|
|
@patch.object(subprocess, "run")
|
|
def test_ffmpeg_exception_fallback(self, mock_run, mock_tmpfile, mock_unlink):
|
|
"""When ffmpeg raises an exception, fallback to char estimation."""
|
|
mock_run.side_effect = Exception("ffmpeg not found")
|
|
mock_tmp = MagicMock()
|
|
mock_tmp.name = "/tmp/fake.mp3"
|
|
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
|
|
mock_tmp.__exit__ = MagicMock(return_value=False)
|
|
mock_tmpfile.return_value = mock_tmp
|
|
|
|
result = _compute_sentence_timings(b"fake_audio", "句子一。句子二。", 6.0)
|
|
|
|
# Should fallback to char estimation
|
|
self.assertEqual(len(result), 2)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 3.0)
|
|
self.assertAlmostEqual(result[1]["start_time"], 3.0)
|
|
self.assertAlmostEqual(result[1]["end_time"], 6.0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|