add sentences borders enable in pc player

This commit is contained in:
2026-09-10 08:58:33 +08:00
parent 39896a5c24
commit 9980f78cfc
7 changed files with 556 additions and 23 deletions

View File

@@ -10,8 +10,11 @@ pip 安装依赖后即可直接播放视频。
- 播放 / 暂停 / 停止、上一首 / 下一首 - 播放 / 暂停 / 停止、上一首 / 下一首
- 前进 / 后退(步长可自定义)与进度条点击拖动定位 - 前进 / 后退(步长可自定义)与进度条点击拖动定位
- 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载 - 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载
- 按句跳转:勾选后 `←` / `→` 自动播放上一句/下一句(基于音频静音自动识别 - 按句跳转:`, / .` 直接跳转上一句/下一句;勾选“按句跳转”后 `← / →` 也可
句边界,无需联网,分析结果自动缓存) 跳句。句边界通过线上服务生成,PC 仅上传本地提取的音频轨,服务器处理完成
后返回边界且不保存音频;结果按文件 SHA-256 缓存在本机。
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
- 音量调节、静音,0.5x - 2.0x 变速播放 - 音量调节、静音,0.5x - 2.0x 变速播放
- 三种循环模式:关 / 单曲循环 / 列表循环 - 三种循环模式:关 / 单曲循环 / 列表循环
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放) - 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)

View File

@@ -13,7 +13,9 @@
Shift+← / → 后退 / 前进 60 秒 Shift+← / → 后退 / 前进 60 秒
↑ / ↓ 音量 +5 / -5 ↑ / ↓ 音量 +5 / -5
Ctrl+↑ / ↓ 快进步长 +1 / -1 秒(macOS 亦支持 Command+↑ / ↓) Ctrl+↑ / ↓ 快进步长 +1 / -1 秒(macOS 亦支持 Command+↑ / ↓)
勾选“按句跳转”后,←/→ 自动播放上一句/下一句(基于音频静音识别句边界) , / . 上一句 / 下一句
勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句
E 讲解当前句子(AI)
M 静音 / 恢复 M 静音 / 恢复
Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →) Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →)
N / P 下一首 / 上一首 N / P 下一首 / 上一首
@@ -27,19 +29,21 @@
""" """
import collections import collections
import hashlib
import json import json
import os import os
import queue import queue
import sys import sys
import tempfile
import threading import threading
import time import time
import requests
from typing import Any, Dict, Optional
from pathlib import Path from pathlib import Path
import tkinter as tk import tkinter as tk
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
from sentence_analysis import detect_sentence_boundaries
try: try:
from PIL import Image as PILImage, ImageTk from PIL import Image as PILImage, ImageTk
except ImportError: except ImportError:
@@ -73,6 +77,41 @@ LOOP_LABELS = {"off": "循环: 关", "one": "循环: 单曲", "list": "循环:
SPEED_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] SPEED_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
SEEK_STEP = 10 SEEK_STEP = 10
SEEK_STEP_BIG = 60 SEEK_STEP_BIG = 60
DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn"
CLIENT_NAME = "MediaPlayer/1.0 (Desktop)"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as media_file:
for chunk in iter(lambda: media_file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def extract_audio_to_wav(path: Path, output_path: Path) -> None:
"""Extract one mono 16 kHz audio track before uploading it to the server."""
import av
with av.open(str(path)) as source:
source_stream = next((s for s in source.streams if s.type == "audio"), None)
if source_stream is None:
raise RuntimeError("文件中没有音频轨")
resampler = av.AudioResampler(format="s16", layout="mono", rate=16_000)
with av.open(str(output_path), "w", format="wav") as target:
target_stream = target.add_stream("pcm_s16le", rate=16_000, layout="mono")
for packet in source.demux(source_stream):
for frame in packet.decode():
for resampled in resampler.resample(frame):
for encoded in target_stream.encode(resampled):
target.mux(encoded)
for resampled in resampler.resample(None):
for encoded in target_stream.encode(resampled):
target.mux(encoded)
for encoded in target_stream.encode(None):
target.mux(encoded)
def fmt_time(ms): def fmt_time(ms):
"""把毫秒格式化为 mm:ss 或 h:mm:ss。""" """把毫秒格式化为 mm:ss 或 h:mm:ss。"""
if ms is None or ms < 0: if ms is None or ms < 0:
@@ -835,6 +874,8 @@ class MediaPlayerApp(tk.Tk):
settings = self._load_settings() settings = self._load_settings()
self.seek_step = settings["seek_step"] self.seek_step = settings["seek_step"]
self.sentence_mode = tk.BooleanVar(value=settings["sentence_mode"]) self.sentence_mode = tk.BooleanVar(value=settings["sentence_mode"])
self.server_url = settings["server_url"].rstrip("/")
self.auth_token = settings["auth_token"]
self.loop_mode = "list" self.loop_mode = "list"
self._seeking = False self._seeking = False
self._switching_until = 0.0 self._switching_until = 0.0
@@ -842,8 +883,12 @@ class MediaPlayerApp(tk.Tk):
self._muted_volume = None self._muted_volume = None
self._photo = None self._photo = None
self._boundaries = {} self._boundaries = {}
self._media_hashes = {}
self._analysis_running = set() self._analysis_running = set()
self._analysis_done = queue.Queue() self._analysis_done = queue.Queue()
self._ui_events = queue.Queue()
self._explanation_window = None
self._explanation_generation = 0
self.engine = self._create_engine() self.engine = self._create_engine()
if self.engine is None: if self.engine is None:
@@ -893,6 +938,7 @@ class MediaPlayerApp(tk.Tk):
ttk.Button(toolbar, text="打开文件…", command=self.open_files, takefocus=0).pack(side="left", padx=(0, 4)) ttk.Button(toolbar, text="打开文件…", command=self.open_files, takefocus=0).pack(side="left", padx=(0, 4))
ttk.Button(toolbar, text="打开文件夹…", command=self.open_folder, takefocus=0).pack(side="left", padx=(0, 8)) ttk.Button(toolbar, text="打开文件夹…", command=self.open_folder, takefocus=0).pack(side="left", padx=(0, 8))
ttk.Button(toolbar, text="服务…", command=self.configure_server, takefocus=0).pack(side="left")
self.btn_prev = ttk.Button(toolbar, text="上一首", command=self.prev_track, takefocus=0) self.btn_prev = ttk.Button(toolbar, text="上一首", command=self.prev_track, takefocus=0)
self.btn_prev.pack(side="left", padx=2) self.btn_prev.pack(side="left", padx=2)
@@ -996,6 +1042,21 @@ class MediaPlayerApp(tk.Tk):
command=self._on_sentence_mode, command=self._on_sentence_mode,
) )
self.sentence_mode_btn.pack(side="left", padx=(16, 0)) self.sentence_mode_btn.pack(side="left", padx=(16, 0))
self.btn_ai_explain = ttk.Button(
controls,
text="AI讲解 (E)",
command=self.show_ai_explanation,
takefocus=0,
)
self.btn_ai_explain.pack(side="left", padx=(12, 0))
self.sentence_var = tk.StringVar(value="")
self.sentence_label = ttk.Label(
controls,
textvariable=self.sentence_var,
anchor="w",
width=32,
)
self.sentence_label.pack(side="left", padx=(10, 0))
self.seek_step_spin.bind("<Return>", self._on_seek_step_return) self.seek_step_spin.bind("<Return>", self._on_seek_step_return)
self.seek_step_spin.bind("<FocusOut>", lambda _e: self._commit_seek_step()) self.seek_step_spin.bind("<FocusOut>", lambda _e: self._commit_seek_step())
ttk.Label(controls, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True) ttk.Label(controls, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True)
@@ -1035,6 +1096,10 @@ class MediaPlayerApp(tk.Tk):
("<L>", lambda _e: self.cycle_loop()), ("<L>", lambda _e: self.cycle_loop()),
("<bracketleft>", lambda _e: self.step_speed(-1)), ("<bracketleft>", lambda _e: self.step_speed(-1)),
("<bracketright>", lambda _e: self.step_speed(1)), ("<bracketright>", lambda _e: self.step_speed(1)),
("<comma>", lambda _e: self.seek_sentence(-1)),
("<period>", lambda _e: self.seek_sentence(1)),
("<e>", lambda _e: self.show_ai_explanation()),
("<E>", lambda _e: self.show_ai_explanation()),
("<f>", lambda _e: self.toggle_fullscreen()), ("<f>", lambda _e: self.toggle_fullscreen()),
("<F>", lambda _e: self.toggle_fullscreen()), ("<F>", lambda _e: self.toggle_fullscreen()),
("<Escape>", lambda _e: self.leave_fullscreen()), ("<Escape>", lambda _e: self.leave_fullscreen()),
@@ -1090,17 +1155,21 @@ class MediaPlayerApp(tk.Tk):
if self.engine is not None: if self.engine is not None:
while True: while True:
try: try:
done_path = self._analysis_done.get_nowait() done_path, message = self._analysis_done.get_nowait()
except queue.Empty: except queue.Empty:
break break
if done_path == self._current_path() and self.sentence_mode.get(): if done_path == self._current_path() and self.sentence_mode.get():
boundaries = self._boundaries.get(done_path) boundaries = self._boundaries.get(done_path)
if boundaries: if boundaries:
self.status_var.set( self.status_var.set(message or f"句边界已就绪:共 {len(boundaries['sentences'])} 句")
f"句边界分析完成:识别到 {len(boundaries)} 句,←/→ 将按句跳转"
)
else: else:
self.status_var.set("该文件无法识别句子边界,按句跳转不可用") self.status_var.set(message or "该文件无法识别句子边界,按句跳转不可用")
while True:
try:
callback = self._ui_events.get_nowait()
except queue.Empty:
break
callback()
for event in self.engine.poll_events(): for event in self.engine.poll_events():
if event == "end": if event == "end":
self._on_media_end() self._on_media_end()
@@ -1115,6 +1184,9 @@ class MediaPlayerApp(tk.Tk):
self.time_var.set(f"{fmt_time(pos)} / {fmt_time(length)}") self.time_var.set(f"{fmt_time(pos)} / {fmt_time(length)}")
if length > 0: if length > 0:
self.seekbar.set_frac(pos / length) self.seekbar.set_frac(pos / length)
sentence = self._current_sentence()
text = str(sentence.get("text") or "").strip() if sentence else ""
self.sentence_var.set(text)
playing = self.engine.is_playing() playing = self.engine.is_playing()
self.btn_play.config(text="暂停" if playing else "播放") self.btn_play.config(text="暂停" if playing else "播放")
self.after(33, self._tick) self.after(33, self._tick)
@@ -1194,7 +1266,17 @@ class MediaPlayerApp(tk.Tk):
step = SEEK_STEP step = SEEK_STEP
if not 1 <= step <= 600: if not 1 <= step <= 600:
step = SEEK_STEP step = SEEK_STEP
return {"seek_step": step, "sentence_mode": bool(data.get("sentence_mode", False))} server_url = str(data.get("server_url") or DEFAULT_SERVER_URL).strip()
if server_url.lower().startswith("http://") or server_url.lower().startswith("https://"):
server_url = server_url.rstrip("/")
else:
server_url = DEFAULT_SERVER_URL
return {
"seek_step": step,
"sentence_mode": bool(data.get("sentence_mode", False)),
"server_url": server_url,
"auth_token": str(data.get("auth_token") or "").strip(),
}
def _save_settings(self): def _save_settings(self):
try: try:
@@ -1203,6 +1285,8 @@ class MediaPlayerApp(tk.Tk):
{ {
"seek_step": self.seek_step, "seek_step": self.seek_step,
"sentence_mode": bool(self.sentence_mode.get()), "sentence_mode": bool(self.sentence_mode.get()),
"server_url": self.server_url,
"auth_token": self.auth_token,
}, },
ensure_ascii=False, ensure_ascii=False,
indent=2, indent=2,
@@ -1297,6 +1381,7 @@ class MediaPlayerApp(tk.Tk):
self.engine.stop() self.engine.stop()
self._seeking = False self._seeking = False
self.time_var.set("00:00 / --:--") self.time_var.set("00:00 / --:--")
self.sentence_var.set("")
self.seekbar.set_frac(0.0) self.seekbar.set_frac(0.0)
self.btn_play.config(text="播放") self.btn_play.config(text="播放")
canvas = getattr(self, "video_canvas", None) canvas = getattr(self, "video_canvas", None)
@@ -1372,46 +1457,186 @@ class MediaPlayerApp(tk.Tk):
self._ensure_sentence_boundaries(path) self._ensure_sentence_boundaries(path)
self.status_var.set("正在分析句边界…") self.status_var.set("正在分析句边界…")
elif self._boundaries[path]: elif self._boundaries[path]:
self.status_var.set(f"按句跳转已开启(识别到 {len(self._boundaries[path])} 句)") count = len(self._boundaries[path]["sentences"])
self.status_var.set(f"按句跳转已开启(识别到 {count} 句)")
else: else:
self.status_var.set("该文件无法识别句子边界,按句跳转不可用") self.status_var.set("该文件无法识别句子边界,按句跳转不可用")
def _ensure_sentence_boundaries(self, path): def _ensure_sentence_boundaries(self, path):
if path in self._boundaries or path in self._analysis_running: if path in self._boundaries or path in self._analysis_running:
return return
if not self.server_url:
self._boundaries[path] = {"sentences": []}
self._analysis_done.put((path, "请先在“服务…”中配置服务器地址"))
return
self._analysis_running.add(path) self._analysis_running.add(path)
self.status_var.set("正在从服务器生成句边界…")
threading.Thread(target=self._analyze_sentences, args=(path,), daemon=True).start() threading.Thread(target=self._analyze_sentences, args=(path,), daemon=True).start()
def _analyze_sentences(self, path): def _analyze_sentences(self, path):
boundaries = detect_sentence_boundaries(path) error = ""
self._boundaries[path] = boundaries document = None
audio_path = None
try:
media_hash = sha256_file(Path(path))
self._media_hashes[path] = media_hash
document = self._load_cached_boundaries(media_hash)
if document is None:
audio_path = self._make_audio_upload(path)
document = self._generate_remote_boundaries(audio_path)
self._save_cached_boundaries(media_hash, document)
except Exception as exc:
error = str(exc) or exc.__class__.__name__
finally:
if audio_path is not None:
try:
Path(audio_path).unlink(missing_ok=True)
except OSError:
pass
self._analysis_running.discard(path) self._analysis_running.discard(path)
self._analysis_done.put(path) if document is not None and document.get("sentences"):
self._boundaries[path] = document
message = error or f"句边界已生成:共 {len(document['sentences'])} 句,已保存到本地"
self._analysis_done.put((path, message))
else:
self._boundaries[path] = {"sentences": []}
self._analysis_done.put((path, error or "服务器未能识别句子边界"))
def _boundary_cache_path(self, media_hash: str) -> Path:
return Path.home() / ".mediaplayer_sentence_boundaries" / f"{media_hash}.json"
def _load_cached_boundaries(self, media_hash: str) -> Optional[Dict[str, Any]]:
path = self._boundary_cache_path(media_hash)
try:
data = json.loads(path.read_text(encoding="utf-8"))
sentences = data["sentences"]
if not isinstance(sentences, list):
return None
cleaned = []
previous_end = 0
for index, item in enumerate(sentences):
start = max(0, int(item["start_ms"]))
end = int(item["end_ms"])
if end <= start or start < previous_end:
raise ValueError("invalid cached boundary")
cleaned.append({
"index": index,
"start_ms": start,
"end_ms": end,
"text": (str(item["text"]) if item.get("text") is not None else None),
"language": item.get("language"),
})
previous_end = end
if not cleaned:
return None
return {"sentences": cleaned, "duration_ms": int(data.get("duration_ms") or previous_end)}
except Exception:
return None
def _save_cached_boundaries(self, media_hash: str, document: Dict[str, Any]) -> None:
path = self._boundary_cache_path(media_hash)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".tmp")
payload = {
"version": 1,
"media_sha256": media_hash,
"duration_ms": int(document.get("duration_ms") or 0),
"sentences": document.get("sentences", []),
}
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
def _make_audio_upload(self, path):
source = Path(path)
if source.suffix.lower() in AUDIO_EXTS:
return source
handle = tempfile.NamedTemporaryFile(prefix="mediaplayer-audio-", suffix=".wav", delete=False)
output = Path(handle.name)
handle.close()
extract_audio_to_wav(source, output)
return output
def _generate_remote_boundaries(self, audio_path) -> Dict[str, Any]:
filename = Path(audio_path).name
content_type = "audio/wav" if filename.lower().endswith(".wav") else "application/octet-stream"
headers = {"User-Agent": CLIENT_NAME}
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
with Path(audio_path).open("rb") as audio_file:
response = requests.post(
self.server_url + "/api/v1/audio/sentence-boundaries",
headers=headers,
files={"file": (filename, audio_file, content_type)},
data={"language": ""},
timeout=(15, 3600),
)
if response.status_code == 401:
raise RuntimeError("请先在“服务…”中登录")
if response.status_code not in range(200, 300):
detail = self._server_error_detail(response)
raise RuntimeError(f"句边界服务返回 {response.status_code}:{detail}")
data = response.json()
if not data.get("sentences"):
raise RuntimeError("服务器未能识别句子边界")
return data
@staticmethod
def _server_error_detail(response) -> str:
try:
data = response.json()
detail = data.get("detail", response.text)
except Exception:
detail = response.text
detail = str(detail)
return detail if len(detail) <= 300 else detail[:297] + "..."
def _current_sentence(self) -> Optional[Dict[str, Any]]:
path = self._current_path()
if path is None:
return None
sentences = (self._boundaries.get(path) or {}).get("sentences") or []
pos = self.engine.get_time()
if pos < 0 or not sentences:
return None
pos_ms = max(0, int(pos))
current = None
for sentence in sentences:
if pos_ms + 150 < int(sentence["start_ms"]):
break
current = sentence
return current
def seek_sentence(self, direction):
return self._seek_sentence(direction)
def _seek_sentence(self, direction): def _seek_sentence(self, direction):
path = self._current_path() path = self._current_path()
if path is None: if path is None:
return False return False
boundaries = self._boundaries.get(path) sentences = (self._boundaries.get(path) or {}).get("sentences") or []
if not boundaries: if not sentences:
return False return False
pos = self.engine.get_time() pos = self.engine.get_time()
if pos < 0: if pos < 0:
return False return False
pos_sec = pos / 1000.0 pos_sec = pos / 1000.0
if direction > 0: if direction > 0:
for boundary in boundaries: for sentence in sentences:
boundary = int(sentence["start_ms"]) / 1000.0
if boundary > pos_sec + 0.15: if boundary > pos_sec + 0.15:
self.seek_to(int(boundary * 1000)) self.seek_to(int(sentence["start_ms"]))
return True return True
return False return False
target = 0.0 target = 0.0
for boundary in reversed(boundaries): target_ms = 0
for sentence in reversed(sentences):
boundary = int(sentence["start_ms"]) / 1000.0
if boundary < pos_sec - 0.25: if boundary < pos_sec - 0.25:
target = boundary target = boundary
target_ms = int(sentence["start_ms"])
break break
if target > 0.0 or pos_sec > 0.25: if target > 0.0 or pos_sec > 0.25:
self.seek_to(int(target * 1000)) self.seek_to(target_ms)
return True return True
return False return False
@@ -1527,6 +1752,162 @@ class MediaPlayerApp(tk.Tk):
self._save_playlist() self._save_playlist()
self.status_var.set("播放列表已清空") self.status_var.set("播放列表已清空")
def configure_server(self):
window = tk.Toplevel(self)
window.title("服务配置")
window.geometry("420x230")
window.transient(self)
window.resizable(False, False)
frame = ttk.Frame(window, padding=16)
frame.pack(fill="both", expand=True)
frame.columnconfigure(1, weight=1)
ttk.Label(frame, text="服务器地址").grid(row=0, column=0, sticky="w", pady=5)
server_var = tk.StringVar(value=self.server_url)
ttk.Entry(frame, textvariable=server_var, width=40).grid(row=0, column=1, sticky="ew")
ttk.Label(frame, text="账号").grid(row=1, column=0, sticky="w", pady=5)
username_var = tk.StringVar()
ttk.Entry(frame, textvariable=username_var).grid(row=1, column=1, sticky="ew")
ttk.Label(frame, text="密码").grid(row=2, column=0, sticky="w", pady=5)
password_var = tk.StringVar()
ttk.Entry(frame, textvariable=password_var, show="*").grid(row=2, column=1, sticky="ew")
ttk.Label(frame, text="登录令牌").grid(row=3, column=0, sticky="w", pady=5)
token_var = tk.StringVar(value=self.auth_token)
ttk.Entry(frame, textvariable=token_var, show="*").grid(row=3, column=1, sticky="ew")
message = tk.StringVar(value="已有令牌可直接保存;没有令牌时输入账号密码登录。")
ttk.Label(frame, textvariable=message, wraplength=360, foreground="#666666").grid(
row=4, column=0, columnspan=2, sticky="ew", pady=(12, 8)
)
buttons = ttk.Frame(frame)
buttons.grid(row=5, column=0, columnspan=2, sticky="e")
def save():
url = server_var.get().strip().rstrip("/")
if not url.lower().startswith(("http://", "https://")):
messagebox.showerror("服务配置", "服务器地址必须以 http:// 或 https:// 开头。", parent=window)
return
self.server_url = url
self.auth_token = token_var.get().strip()
self._save_settings()
window.destroy()
self.status_var.set("服务配置已保存")
def login_and_save():
url = server_var.get().strip().rstrip("/")
try:
response = requests.post(
url + "/api/v1/auth/login",
json={"username": username_var.get().strip(), "password": password_var.get()},
headers={"User-Agent": CLIENT_NAME},
timeout=(10, 20),
)
if response.status_code not in range(200, 300):
raise RuntimeError(self._server_error_detail(response))
token_var.set(response.json()["token"])
message.set("登录成功,令牌已保存。")
self.server_url = url
self.auth_token = token_var.get().strip()
self._save_settings()
window.after(500, window.destroy)
except Exception as exc:
messagebox.showerror("登录失败", str(exc), parent=window)
ttk.Button(buttons, text="登录并保存", command=login_and_save, takefocus=0).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="保存", command=save, takefocus=0).pack(side="left")
def show_ai_explanation(self):
if not self.server_url:
self.configure_server()
return
sentence = self._current_sentence()
if not sentence:
self.status_var.set("当前没有可用于讲解的句子边界")
return
sentence_text = str(sentence.get("text") or "").strip()
if not sentence_text:
self.status_var.set("当前句子没有文字内容,AI 讲解不可用")
return
self._explanation_generation += 1
generation = self._explanation_generation
if self._explanation_window is not None:
try:
self._explanation_window.destroy()
except tk.TclError:
pass
window = tk.Toplevel(self)
window.title("AI 句子讲解")
window.geometry("480x380")
window.transient(self)
frame = ttk.Frame(window, padding=14)
frame.pack(fill="both", expand=True)
frame.columnconfigure(0, weight=1)
frame.rowconfigure(1, weight=1)
ttk.Label(frame, text=sentence_text, wraplength=440, justify="left", font=("Helvetica", 12, "bold")).grid(
row=0, column=0, sticky="ew", pady=(0, 10)
)
text = tk.Text(frame, wrap="word", state="disabled", relief="flat", background="#f6f6f6")
text.grid(row=1, column=0, sticky="nsew")
text.insert("1.0", "AI 正在讲解这句话…")
ttk.Button(frame, text="关闭", command=window.destroy, takefocus=0).grid(row=2, column=0, sticky="e", pady=(10, 0))
self._explanation_window = window
path = self._current_path() or ""
course_title = Path(path).stem
threading.Thread(
target=self._explain_sentence_worker,
args=(sentence, course_title, generation),
daemon=True,
).start()
def _explain_sentence_worker(self, sentence, course_title, generation):
try:
headers = {"Content-Type": "application/json", "User-Agent": CLIENT_NAME}
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
payload = {
"sentence_text": sentence.get("text", ""),
"course_title": course_title,
"sentence_index": sentence.get("index"),
"language": sentence.get("language"),
}
response = requests.post(
self.server_url + "/api/v1/sentences/explain",
headers=headers,
json=payload,
timeout=(15, 90),
)
if response.status_code == 401:
raise RuntimeError("登录已过期,请在“服务…”中重新登录")
if response.status_code not in range(200, 300):
raise RuntimeError(f"AI 讲解服务返回 {response.status_code}:{self._server_error_detail(response)}")
explanation = response.json()["explanation"]
self._ui_events.put(lambda: self._show_explanation_result(explanation, generation))
except Exception as exc:
message = str(exc) or exc.__class__.__name__
self._ui_events.put(lambda: self._show_explanation_result("讲解失败:" + message, generation, error=True))
def _show_explanation_result(self, explanation, generation, error=False):
if generation != self._explanation_generation:
return
window = self._explanation_window
if window is None:
return
try:
text = window.nametowidget(window.grid_slaves(row=1, column=0)[0])
text.configure(state="normal")
text.delete("1.0", "end")
text.insert("1.0", explanation)
text.configure(state="disabled")
if error:
window.title("AI 讲解失败")
except (tk.TclError, IndexError):
pass
def _update_status(self): def _update_status(self):
parts = [f"循环:{LOOP_LABELS[self.loop_mode].split(':')[1].strip()}"] parts = [f"循环:{LOOP_LABELS[self.loop_mode].split(':')[1].strip()}"]
parts.append(f"速度 {self.rate:g}x") parts.append(f"速度 {self.rate:g}x")

View File

@@ -1,4 +1,5 @@
av>=12.0 av>=12.0
requests>=2.32.0
sounddevice>=0.4.6 sounddevice>=0.4.6
numpy>=1.26 numpy>=1.26
Pillow>=10.0 Pillow>=10.0

View File

@@ -52,6 +52,7 @@ GET /api/v1/videos
GET /api/v1/videos/{sha256} GET /api/v1/videos/{sha256}
GET /api/v1/videos/{sha256}/content GET /api/v1/videos/{sha256}/content
GET /api/v1/videos/{sha256}/sentence-boundaries GET /api/v1/videos/{sha256}/sentence-boundaries
POST /api/v1/audio/sentence-boundaries
POST /api/v1/sentences/explain POST /api/v1/sentences/explain
POST /api/v1/videos/{sha256}/sentences/{index}/assessments POST /api/v1/videos/{sha256}/sentences/{index}/assessments
@@ -67,6 +68,11 @@ DELETE /api/v1/admin/videos/{sha256}
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
requests use `X-Client-Key` when `CLIENT_API_KEY` is configured. requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
`POST /api/v1/audio/sentence-boundaries` accepts an audio file from an
authenticated client, generates boundaries in memory/temp storage only, and
returns a sentence document. It does not register or archive the uploaded
audio.
## AI Sentence Explanation ## AI Sentence Explanation
Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`. Configure an OpenAI-compatible chat-completions endpoint in `sentence_api/.env`.

View File

@@ -2,6 +2,7 @@ import hashlib
import hmac import hmac
import logging import logging
import mimetypes import mimetypes
import tempfile
import uuid import uuid
import urllib.request import urllib.request
import urllib.error import urllib.error
@@ -57,7 +58,7 @@ from .models import (
VideoUploadResponse, VideoUploadResponse,
) )
from .oss import VolcanoOSSUploader from .oss import VolcanoOSSUploader
from .processing import VideoProcessor, split_sentence_text from .processing import VideoProcessor, generate_audio_boundary_document, split_sentence_text
from .repository import VideoRepository from .repository import VideoRepository
from .store import BoundaryStore from .store import BoundaryStore
from .transcription import MossTranscriber, Transcriber from .transcription import MossTranscriber, Transcriber
@@ -72,6 +73,7 @@ SHA256_PATH = ApiPath(
) )
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"} ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".mkv", ".webm"}
ALLOWED_AUDIO_EXTENSIONS = {".m4a", ".mp4", ".wav"} ALLOWED_AUDIO_EXTENSIONS = {".m4a", ".mp4", ".wav"}
MAX_AUDIO_BOUNDARY_BYTES = 512 * 1024 * 1024
class UploadTooLargeError(ValueError): class UploadTooLargeError(ValueError):
@@ -329,6 +331,47 @@ def create_app(
raise HTTPException(status_code=503, detail=str(exc)) from exc raise HTTPException(status_code=503, detail=str(exc)) from exc
return SentenceExplanationResponse(explanation=explanation) return SentenceExplanationResponse(explanation=explanation)
@application.post(
"/api/v1/audio/sentence-boundaries",
response_model=SentenceBoundaryDocument,
)
async def generate_sentence_boundaries(
file: UploadFile = File(...),
language: Optional[str] = Form(default=None, max_length=32),
user: AuthenticatedUser = Depends(current_user),
) -> SentenceBoundaryDocument:
del user
max_bytes = min(MAX_AUDIO_BOUNDARY_BYTES, service_settings.max_upload_bytes)
with tempfile.TemporaryDirectory(prefix="audio-boundaries-") as temp_dir:
audio_path = Path(temp_dir) / "audio.wav"
try:
audio_hash, _size = await run_in_threadpool(
_persist_upload,
file.file,
audio_path,
max_bytes,
)
document = await run_in_threadpool(
generate_audio_boundary_document,
video_hash=audio_hash,
audio_path=audio_path,
transcriber=moss,
language=language,
)
except UploadTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Ad-hoc audio sentence boundary generation failed.")
raise HTTPException(
status_code=502,
detail="Sentence boundary generation failed. Please try again later.",
) from exc
finally:
await file.close()
return document
@application.delete("/api/v1/courses/{video_hash}/enroll", status_code=204) @application.delete("/api/v1/courses/{video_hash}/enroll", status_code=204)
def unenroll_course( def unenroll_course(
video_hash: str = SHA256_PATH, video_hash: str = SHA256_PATH,

View File

@@ -14,7 +14,8 @@ from .audio_metrics import (
refine_sentence_start_ms, refine_sentence_start_ms,
) )
from .config import Settings from .config import Settings
from .generate_boundaries import ALGORITHM_VERSION, make_entry from .generate_boundaries import ALGORITHM_VERSION, make_entry, media_duration_ms
from sentence_analysis import detect_sentence_boundaries
from .models import SentenceBoundary, SentenceBoundaryDocument from .models import SentenceBoundary, SentenceBoundaryDocument
from .oss import VolcanoOSSUploader from .oss import VolcanoOSSUploader
from .repository import VideoRepository from .repository import VideoRepository
@@ -27,6 +28,7 @@ from .transcription import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MOSS_ALGORITHM_VERSION = "moss-punctuation-v3" MOSS_ALGORITHM_VERSION = "moss-punctuation-v3"
SILENCE_ALGORITHM_VERSION = "silence-rms-v1"
class VideoProcessor: class VideoProcessor:
@@ -417,6 +419,67 @@ def document_from_transcript(
) )
def generate_audio_boundary_document(
*,
video_hash: str,
audio_path: Path,
transcriber: Transcriber,
language: Optional[str] = None,
) -> SentenceBoundaryDocument:
"""Generate sentence boundaries for a client-supplied audio file.
The caller owns ``audio_path`` and is responsible for deleting it. Nothing
in this function copies or registers the audio in application storage.
"""
duration_ms = media_duration_ms(audio_path)
transcript = None
if transcriber.available:
try:
transcript = transcriber.transcribe(audio_path, language)
except Exception as exc:
logger.warning("Audio boundary transcription failed; using silence fallback: %s", exc)
if transcript is not None:
document = document_from_transcript(
video_hash=video_hash,
duration_ms=duration_ms,
transcript=transcript,
language=language,
audio_path=audio_path,
)
if document.sentences:
return document
starts = detect_sentence_boundaries(audio_path)
if not starts:
raise ValueError("No sentence boundaries could be detected in the audio.")
starts_ms = sorted({max(0, min(duration_ms - 1, int(round(start * 1000)))) for start in starts})
sentences: List[SentenceBoundary] = []
for index, start_ms in enumerate(starts_ms):
end_ms = starts_ms[index + 1] if index + 1 < len(starts_ms) else duration_ms
if end_ms <= start_ms:
continue
sentences.append(
SentenceBoundary(
index=len(sentences),
start_ms=start_ms,
end_ms=end_ms,
text=None,
language=language,
)
)
if not sentences:
raise ValueError("Detected audio boundaries do not form valid sentence ranges.")
return SentenceBoundaryDocument(
video_hash=video_hash,
duration_ms=duration_ms,
algorithm_version=SILENCE_ALGORITHM_VERSION,
sentences=sentences,
)
def _media_duration_ms(path: Path) -> int: def _media_duration_ms(path: Path) -> int:
from .generate_boundaries import media_duration_ms from .generate_boundaries import media_duration_ms

View File

@@ -80,6 +80,42 @@ def test_invalid_hash_is_rejected(tmp_path):
assert response.status_code == 422 assert response.status_code == 422
def test_adhoc_audio_boundaries_do_not_persist_audio(tmp_path, monkeypatch):
client = make_client(tmp_path)
upload = tmp_path / "audio.wav"
upload.write_bytes(b"not-a-real-audio")
import sentence_api.processing as processing_module
monkeypatch.setattr(processing_module, "detect_sentence_boundaries", lambda path: [0.0, 1.0])
monkeypatch.setattr(processing_module, "media_duration_ms", lambda path: 2000)
response = client.post(
"/api/v1/audio/sentence-boundaries",
files={"file": ("audio.wav", upload.read_bytes(), "audio/wav")},
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["algorithm_version"] == "silence-rms-v1"
assert payload["duration_ms"] == 2000
assert [(item["start_ms"], item["end_ms"]) for item in payload["sentences"]] == [(0, 1000), (1000, 2000)]
assert not (tmp_path / "data" / "work").exists() or not any((tmp_path / "data" / "work").iterdir())
assert not (tmp_path / "data" / "v").exists() or not any((tmp_path / "data" / "v").iterdir())
def test_adhoc_audio_boundaries_require_login(tmp_path):
client = make_client(tmp_path)
client.headers.clear()
response = client.post(
"/api/v1/audio/sentence-boundaries",
files={"file": ("audio.wav", b"audio", "audio/wav")},
)
assert response.status_code == 401
def test_sentence_explanation_requires_login(tmp_path): def test_sentence_explanation_requires_login(tmp_path):
index_path = tmp_path / "boundaries.json" index_path = tmp_path / "boundaries.json"
index_path.write_text("{}", encoding="utf-8") index_path.write_text("{}", encoding="utf-8")