This commit is contained in:
2026-02-03 10:20:43 +03:00
parent 007d700a8e
commit 011f7be20f
4 changed files with 225 additions and 24 deletions

View File

@@ -12,6 +12,7 @@ async def save_voice_message(
user_id: int,
scenario_id: str,
replica_index: int,
duration: int,
) -> None:
"""Сохраняет голосовое сообщение в data_partial/."""
# Создаём директорию если нужно
@@ -25,7 +26,25 @@ async def save_voice_message(
await file.download_to_drive(filepath)
# Записываем в БД
upsert_recording(user_id, scenario_id, replica_index)
# Записываем в БД (duration из метаданных Telegram)
upsert_recording(user_id, scenario_id, replica_index, float(duration))
logger.debug(f"Saved voice: {filepath}")
logger.debug(f"Saved voice: {filepath} ({duration}s)")
def format_duration(seconds: float) -> str:
"""Форматирует длительность в читаемый вид."""
seconds = int(seconds)
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
parts = []
if hours > 0:
parts.append(f"{hours} ч")
if minutes > 0:
parts.append(f"{minutes} мин")
if secs > 0 or not parts:
parts.append(f"{secs} сек")
return " ".join(parts)

View File

@@ -61,6 +61,7 @@ class Recording:
user_id: int # dataset_speaker_id
scenario_id: str
replica_index: int
duration: float # длительность в секундах
created_at: datetime
@@ -120,6 +121,7 @@ def init_db() -> None:
user_id INTEGER NOT NULL,
scenario_id TEXT NOT NULL,
replica_index INTEGER NOT NULL,
duration REAL DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (scenario_id) REFERENCES scenarios(id),
@@ -147,6 +149,14 @@ def init_db() -> None:
CREATE INDEX IF NOT EXISTS idx_recordings_scenario
ON recordings(scenario_id);
""")
# Миграция: добавляем колонку duration если её нет
cursor = conn.execute("PRAGMA table_info(recordings)")
columns = [row[1] for row in cursor.fetchall()]
if "duration" not in columns:
conn.execute("ALTER TABLE recordings ADD COLUMN duration REAL DEFAULT 0.0")
logger.info("Добавлена колонка duration в таблицу recordings")
conn.commit()
logger.info("База данных инициализирована")
@@ -317,9 +327,9 @@ def create_recording(user_id: int, scenario_id: str, replica_index: int) -> Reco
"""Создаёт запись об озвучке реплики."""
with get_connection() as conn:
cursor = conn.execute(
"INSERT INTO recordings (user_id, scenario_id, replica_index) "
"VALUES (?, ?, ?) "
"RETURNING id, user_id, scenario_id, replica_index, created_at",
"INSERT INTO recordings (user_id, scenario_id, replica_index, duration) "
"VALUES (?, ?, ?, 0.0) "
"RETURNING id, user_id, scenario_id, replica_index, duration, created_at",
(user_id, scenario_id, replica_index),
)
row = cursor.fetchone()
@@ -329,22 +339,25 @@ def create_recording(user_id: int, scenario_id: str, replica_index: int) -> Reco
user_id=row["user_id"],
scenario_id=row["scenario_id"],
replica_index=row["replica_index"],
duration=row["duration"],
created_at=row["created_at"],
)
def upsert_recording(user_id: int, scenario_id: str, replica_index: int) -> Recording:
def upsert_recording(
user_id: int, scenario_id: str, replica_index: int, duration: float
) -> Recording:
"""Создаёт или обновляет запись об озвучке реплики."""
with get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO recordings (user_id, scenario_id, replica_index)
VALUES (?, ?, ?)
INSERT INTO recordings (user_id, scenario_id, replica_index, duration)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, scenario_id, replica_index)
DO UPDATE SET created_at = CURRENT_TIMESTAMP
RETURNING id, user_id, scenario_id, replica_index, created_at
DO UPDATE SET created_at = CURRENT_TIMESTAMP, duration = excluded.duration
RETURNING id, user_id, scenario_id, replica_index, duration, created_at
""",
(user_id, scenario_id, replica_index),
(user_id, scenario_id, replica_index, duration),
)
row = cursor.fetchone()
conn.commit()
@@ -353,6 +366,7 @@ def upsert_recording(user_id: int, scenario_id: str, replica_index: int) -> Reco
user_id=row["user_id"],
scenario_id=row["scenario_id"],
replica_index=row["replica_index"],
duration=row["duration"],
created_at=row["created_at"],
)
@@ -361,7 +375,7 @@ def get_user_recordings_for_scenario(user_id: int, scenario_id: str) -> list[Rec
"""Получает все записи пользователя для сценария."""
with get_connection() as conn:
cursor = conn.execute(
"SELECT id, user_id, scenario_id, replica_index, created_at "
"SELECT id, user_id, scenario_id, replica_index, duration, created_at "
"FROM recordings WHERE user_id = ? AND scenario_id = ? "
"ORDER BY replica_index",
(user_id, scenario_id),
@@ -372,6 +386,7 @@ def get_user_recordings_for_scenario(user_id: int, scenario_id: str) -> list[Rec
user_id=row["user_id"],
scenario_id=row["scenario_id"],
replica_index=row["replica_index"],
duration=row["duration"],
created_at=row["created_at"],
)
for row in cursor.fetchall()
@@ -501,6 +516,32 @@ def get_stats() -> dict:
)
""").fetchone()[0]
# Количество полностью озвученных сценариев (все дорожки сценария озвучены)
stats["completed_scenarios"] = conn.execute("""
SELECT COUNT(*) FROM scenarios s
WHERE (
SELECT COUNT(DISTINCT speaker_id) FROM replicas WHERE scenario_id = s.id
) = (
SELECT COUNT(DISTINCT speaker_id) FROM (
SELECT r.scenario_id, rep.speaker_id, COUNT(*) as cnt
FROM recordings r
JOIN replicas rep ON r.scenario_id = rep.scenario_id
AND r.replica_index = rep.replica_index
WHERE r.scenario_id = s.id
GROUP BY r.scenario_id, rep.speaker_id
HAVING cnt = (
SELECT COUNT(*) FROM replicas rp
WHERE rp.scenario_id = r.scenario_id
AND rp.speaker_id = rep.speaker_id
)
) AS completed
)
""").fetchone()[0]
# Общая длительность всех записей в секундах
result = conn.execute("SELECT SUM(duration) FROM recordings").fetchone()[0]
stats["total_duration"] = result if result else 0.0
return stats
@@ -551,3 +592,51 @@ def delete_scenario_data(scenario_id: str) -> None:
conn.execute("DELETE FROM scenarios WHERE id = ?", (scenario_id,))
conn.commit()
logger.info(f"Deleted scenario {scenario_id} from database")
def get_user_stats(user_id: int) -> dict:
"""Получает статистику пользователя."""
with get_connection() as conn:
stats = {}
# Количество озвученных реплик
stats["total_replicas"] = conn.execute(
"SELECT COUNT(*) FROM recordings WHERE user_id = ?", (user_id,)
).fetchone()[0]
# Количество уникальных сценариев
stats["total_scenarios"] = conn.execute(
"SELECT COUNT(DISTINCT scenario_id) FROM recordings WHERE user_id = ?",
(user_id,),
).fetchone()[0]
# Количество полностью озвученных дорожек
stats["completed_tracks"] = conn.execute(
"""
SELECT COUNT(*) FROM (
SELECT r.scenario_id, rep.speaker_id, COUNT(*) as cnt
FROM recordings r
JOIN replicas rep ON r.scenario_id = rep.scenario_id
AND r.replica_index = rep.replica_index
WHERE r.user_id = ?
GROUP BY r.scenario_id, rep.speaker_id
HAVING cnt = (
SELECT COUNT(*) FROM replicas rp
WHERE rp.scenario_id = r.scenario_id
AND rp.speaker_id = rep.speaker_id
)
)
""",
(user_id,),
).fetchone()[0]
return stats
def get_user_audio_duration(user_id: int) -> float:
"""Получает общую длительность аудиозаписей пользователя в секундах."""
with get_connection() as conn:
result = conn.execute(
"SELECT SUM(duration) FROM recordings WHERE user_id = ?", (user_id,)
).fetchone()[0]
return result if result else 0.0

View File

@@ -15,7 +15,9 @@ from src.database import (
get_scenario,
get_scenario_stats,
get_stats,
get_user_audio_duration,
get_user_session,
get_user_stats,
get_users_in_state,
get_users_with_scenario,
upsert_user_session,
@@ -253,13 +255,17 @@ def format_replica_message(session: UserSession) -> str:
def format_admin_stats() -> str:
"""Форматирует статистику для админки."""
from src.audio import format_duration
stats = get_stats()
duration_str = format_duration(stats["total_duration"])
return f"""📊 Статистика датасета:
📁 Сценарии: {stats["total_scenarios"]}
🎵 Дорожки: {stats["total_tracks"]} (завершено: {stats["completed_tracks"]})
💬 Реплики: {stats["total_replicas"]}
🎙 Записей: {stats["total_recordings"]}
📁 Сценарии: {stats["completed_scenarios"]}/{stats["total_scenarios"]} завершено
🎵 Дорожки: {stats["completed_tracks"]}/{stats["total_tracks"]} озвучено
💬 Реплики: {stats["total_recordings"]}/{stats["total_replicas"]} записано
⏱ Объём: {duration_str}
👥 Пользователей: {stats["total_users"]}
Отправьте JSON-файл с новым сценарием для загрузки."""
@@ -434,9 +440,28 @@ async def handle_save_track(
) -> None:
"""Обработчик сохранения дорожки."""
query = update.callback_query
from src.scenarios import move_track_to_data
from src.audio import format_duration
from src.scenarios import is_scenario_complete, move_scenario_to_data
# Переносим файлы только когда весь сценарий озвучен
if is_scenario_complete(session.scenario_id):
move_scenario_to_data(session.scenario_id)
# Получаем статистику пользователя
stats = get_user_stats(user.id)
duration = get_user_audio_duration(user.id)
duration_str = format_duration(duration)
# Формируем сообщение с благодарностью и статистикой
thanks_msg = f"""✅ Дорожка сохранена!
🙏 Спасибо за вашу работу!
📊 Ваша статистика:
• Озвучено реплик: {stats["total_replicas"]}
• Завершено дорожек: {stats["completed_tracks"]}
• Общее время записи: {duration_str}"""
move_track_to_data(user.id, session.scenario_id, session.speaker_id)
track = find_available_track(user.id)
if not track:
@@ -444,9 +469,7 @@ async def handle_save_track(
session.scenario_id = None
session.speaker_id = None
session.replica_index = None
await query.edit_message_text(
f"✅ Дорожка сохранена!\n\n{NO_MORE_SCENARIOS_TEXT}"
)
await query.edit_message_text(f"{thanks_msg}\n\n{NO_MORE_SCENARIOS_TEXT}")
else:
scenario_id, speaker_id = track
session.state = UserState.FIRST_REPLICA
@@ -455,7 +478,7 @@ async def handle_save_track(
session.replica_index = 0
replica_text = get_current_replica_text(session)
await query.edit_message_text(
f"✅ Дорожка сохранена!\n\n{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}"
f"{thanks_msg}\n\n{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}"
)
logger.info(
f"User {user.id} saved track, started new: {scenario_id}/{speaker_id}"
@@ -806,9 +829,18 @@ async def handle_voice_message(
"""Обработчик голосовых сообщений."""
from src.audio import save_voice_message
# Получаем реальный replica_index из таблицы replicas (индекс в сценарии)
replicas = get_replicas_for_track(session.scenario_id, session.speaker_id)
real_replica_index = replicas[session.replica_index].replica_index
voice = update.message.voice
await save_voice_message(
context.bot, voice.file_id, user.id, session.scenario_id, session.replica_index
context.bot,
voice.file_id,
user.id,
session.scenario_id,
real_replica_index,
voice.duration,
)
track_length = get_track_length(session.scenario_id, session.speaker_id)

View File

@@ -208,6 +208,67 @@ def get_audio_filename(replica_index: int, user_id: int) -> str:
return f"{replica_index}_{user_id}.wav"
def is_scenario_complete(scenario_id: str) -> bool:
"""Проверяет, озвучены ли все дорожки сценария (у каждой есть полная запись)."""
with get_connection() as conn:
# Все дорожки с количеством реплик
all_tracks = conn.execute(
"""
SELECT speaker_id, COUNT(*) as replica_count
FROM replicas
WHERE scenario_id = ?
GROUP BY speaker_id
""",
(scenario_id,),
).fetchall()
if not all_tracks:
return False
for track in all_tracks:
speaker_id, replica_count = track[0], track[1]
# Есть ли хотя бы один пользователь, который полностью озвучил дорожку
complete_count = conn.execute(
"""
SELECT COUNT(*) FROM (
SELECT r.user_id
FROM recordings r
JOIN replicas rep ON r.scenario_id = rep.scenario_id
AND r.replica_index = rep.replica_index
WHERE r.scenario_id = ? AND rep.speaker_id = ?
GROUP BY r.user_id
HAVING COUNT(*) = ?
)
""",
(scenario_id, speaker_id, replica_count),
).fetchone()[0]
if complete_count == 0:
return False
return True
def move_scenario_to_data(scenario_id: str) -> None:
"""Переносит все файлы сценария из data_partial в data."""
partial_dir = get_partial_dir(scenario_id)
data_dir = get_data_dir(scenario_id)
if not partial_dir.exists():
return
data_dir.mkdir(parents=True, exist_ok=True)
moved_count = 0
for file in partial_dir.glob("*.wav"):
dst = data_dir / file.name
shutil.move(str(file), str(dst))
moved_count += 1
if moved_count > 0:
logger.info(f"Сценарий {scenario_id} завершён: перенесено {moved_count} файлов")
def move_track_to_data(user_id: int, scenario_id: str, speaker_id: int) -> None:
"""Переносит завершённую дорожку из data_partial в data."""
partial_dir = get_partial_dir(scenario_id)