add sentences borders enable in pc player
This commit is contained in:
@@ -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 变速播放
|
||||||
- 三种循环模式:关 / 单曲循环 / 列表循环
|
- 三种循环模式:关 / 单曲循环 / 列表循环
|
||||||
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
|
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
|
||||||
|
|||||||
419
media_player.py
419
media_player.py
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user