optimized PC player

This commit is contained in:
2026-09-11 12:15:51 +08:00
parent 07261d092b
commit 4b71c6c615
5 changed files with 317 additions and 84 deletions

View File

@@ -35,6 +35,7 @@ import json
import logging
from logging.handlers import RotatingFileHandler
import os
import re
import queue
import sys
import tempfile
@@ -86,6 +87,61 @@ DEBUG_LOG_DIR = Path.home() / ".mediaplayer_logs"
DEBUG_LOG_PATH = DEBUG_LOG_DIR / "mediaplayer.log"
_debug_logger = logging.getLogger("mediaplayer")
_SPEAKER_TAG_RE = re.compile(r"\[(S\d+)\]")
def _split_speaker_tagged_sentences(document: Dict[str, Any]) -> Dict[str, Any]:
"""Split sentences that still contain inline ``[S01]`` speaker tags.
The server should already strip these tags and split at speaker boundaries
(see ``split_sentences_at_punctuation``), but locally cached boundaries
from an older server version may still have the tags embedded. This
post-processing ensures the player always uses clean, per-phrase sentences
for playback navigation, AI explanation, and every other operation.
"""
sentences = document.get("sentences")
if not isinstance(sentences, list) or not sentences:
return document
needs_split = any(_SPEAKER_TAG_RE.search(str(s.get("text") or "")) for s in sentences)
if not needs_split:
return document
cleaned: list = []
for sentence in sentences:
text = str(sentence.get("text") or "")
matches = list(_SPEAKER_TAG_RE.finditer(text))
if not matches:
cleaned.append(sentence)
continue
start_ms = int(sentence["start_ms"])
end_ms = int(sentence["end_ms"])
duration_ms = max(1, end_ms - start_ms)
total = len(matches)
for index, match in enumerate(matches):
piece_start = match.end()
if index + 1 < total:
piece_end = matches[index + 1].start()
else:
piece_end = len(text)
piece = text[piece_start:piece_end].strip()
if not piece:
continue
sub_start = start_ms + int(duration_ms * index / total)
sub_end = start_ms + int(duration_ms * (index + 1) / total)
if sub_end <= sub_start:
sub_end = sub_start + 1
cleaned.append({
**sentence,
"start_ms": sub_start,
"end_ms": sub_end,
"text": piece,
})
for index, sentence in enumerate(cleaned):
sentence["index"] = index
return {**document, "sentences": cleaned}
def setup_debug_logging() -> Path:
"""Write concise diagnostics to a small local rotating log."""
@@ -1103,8 +1159,12 @@ class MediaPlayerApp(tk.Tk):
text="按句跳转",
variable=self.sentence_mode,
command=self._on_sentence_mode,
takefocus=0,
)
self.sentence_mode_btn.pack(side="left", padx=(16, 0))
# 点击 Checkbutton 后 tk 仍会把焦点给它;把空格键重定向到播放/暂停,
# 避免误改复选按钮状态。
self.sentence_mode_btn.bind("<space>", self._on_space)
self.btn_generate_boundaries = ttk.Button(
controls,
text="生成句边界",
@@ -1172,6 +1232,8 @@ class MediaPlayerApp(tk.Tk):
("<period>", lambda _e: self.seek_sentence(1)),
("<e>", lambda _e: self.show_ai_explanation()),
("<E>", lambda _e: self.show_ai_explanation()),
("<a>", lambda _e: self.show_ai_explanation()),
("<A>", lambda _e: self.show_ai_explanation()),
("<f>", lambda _e: self.toggle_fullscreen()),
("<F>", lambda _e: self.toggle_fullscreen()),
("<Escape>", lambda _e: self.leave_fullscreen()),
@@ -1622,7 +1684,7 @@ class MediaPlayerApp(tk.Tk):
document = self._load_cached_boundaries(media_hash)
if document and document.get("sentences"):
self._media_hashes[path] = media_hash
self._boundaries[path] = document
self._boundaries[path] = _split_speaker_tagged_sentences(document)
count = len(document["sentences"])
message = f"已加载本地句边界缓存:共 {count} 句"
debug_log(
@@ -1714,6 +1776,9 @@ class MediaPlayerApp(tk.Tk):
if (self._boundaries.get(path) or {}).get("sentences"):
if not messagebox.askyesno("生成句边界", "当前文件已有句边界,确定要重新生成吗?"):
return
force = True
else:
force = False
self.sentence_mode.set(False)
self._save_settings()
self._analysis_running.add(path)
@@ -1723,11 +1788,11 @@ class MediaPlayerApp(tk.Tk):
self._update_sentence_controls()
threading.Thread(
target=self._analyze_sentences,
args=(path, token),
args=(path, token, force),
daemon=True,
).start()
def _analyze_sentences(self, path, token):
def _analyze_sentences(self, path, token, force=False):
error = ""
document = None
source = "generated"
@@ -1744,19 +1809,26 @@ class MediaPlayerApp(tk.Tk):
)
self._media_hashes[path] = media_hash
if force:
self._report_progress(18, "正在重新生成句边界…")
else:
self._report_progress(18, "正在检查线上是否已有句边界…")
remote_document = self._fetch_remote_boundaries(media_hash)
if remote_document and remote_document.get("sentences"):
document = remote_document
document = _split_speaker_tagged_sentences(document)
source = "server"
self._save_cached_boundaries(media_hash, document)
else:
if document is None and not force:
document = self._load_cached_boundaries(media_hash)
if document and document.get("sentences"):
source = "local"
else:
if document is None:
if not force:
self._report_progress(25, "线上没有句边界,正在准备音频…")
debug_log("audio_extraction_started", media_hash=media_hash)
else:
self._report_progress(25, "正在重新提取音频…")
debug_log("audio_extraction_started", media_hash=media_hash, force=force)
audio_path = self._make_audio_upload(
path,
progress_callback=lambda ratio: self._report_progress(
@@ -1784,6 +1856,7 @@ class MediaPlayerApp(tk.Tk):
)
),
)
document = _split_speaker_tagged_sentences(document)
self._save_cached_boundaries(media_hash, document)
source = "generated"
except Exception as exc:
@@ -1800,6 +1873,7 @@ class MediaPlayerApp(tk.Tk):
self._analysis_running.discard(path)
if document is not None and document.get("sentences"):
document = _split_speaker_tagged_sentences(document)
self._boundaries[path] = document
count = len(document["sentences"])
if source == "server":
@@ -2367,54 +2441,138 @@ class MediaPlayerApp(tk.Tk):
pass
window = tk.Toplevel(self)
window.title("AI 句子讲解")
window.geometry("480x380")
window.geometry("1000x800")
window.minsize(1000, 800)
window.configure(background="#1e1e2e")
window.transient(self)
frame = ttk.Frame(window, padding=14)
# Dark theme palette.
bg = "#1e1e2e"
card_bg = "#2a2a3d"
fg_primary = "#cdd6f4"
fg_secondary = "#9399b2"
accent = "#89b4fa"
accent_hover = "#74c7ec"
frame = tk.Frame(window, bg=bg, padx=16, pady=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",
foreground="#111111",
font=("Helvetica", 12, "bold"),
).grid(
row=0, column=0, sticky="ew", pady=(0, 10)
frame.rowconfigure(2, weight=1)
font_size = [20]
# ── Top toolbar panel ─────────────────────────────────────────────
# Fixed-height, always visible: title | A− 20pt A+ | ✕
toolbar = tk.Frame(frame, bg=card_bg, padx=12, pady=8)
toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 10))
toolbar.columnconfigure(1, weight=1)
tk.Label(
toolbar,
text="AI 句子讲解",
bg=card_bg,
fg=fg_primary,
font=("Helvetica", 13, "bold"),
).grid(row=0, column=0, sticky="w")
font_controls = tk.Frame(toolbar, bg=card_bg)
font_controls.grid(row=0, column=2, sticky="e")
def _apply_font_size():
size_label.configure(text=f"{font_size[0]}pt")
sentence_label.configure(font=("Helvetica", font_size[0], "bold"))
text.configure(font=("Helvetica", font_size[0]))
def _set_font_size(delta):
font_size[0] = max(12, min(36, font_size[0] + delta))
_apply_font_size()
tk.Button(
font_controls, text="A\u2212", command=lambda: _set_font_size(-2),
bg=accent, fg=bg, activebackground=accent_hover, activeforeground=bg,
relief="flat", padx=10, pady=4, font=("Helvetica", 12, "bold"),
takefocus=0, cursor="hand2",
).pack(side="left", padx=(0, 4))
size_label = tk.Label(
font_controls, text=f"{font_size[0]}pt",
bg=card_bg, fg=fg_primary,
font=("Helvetica", 11), width=4, anchor="center",
)
size_label.pack(side="left")
tk.Button(
font_controls, text="A+", command=lambda: _set_font_size(2),
bg=accent, fg=bg, activebackground=accent_hover, activeforeground=bg,
relief="flat", padx=10, pady=4, font=("Helvetica", 12, "bold"),
takefocus=0, cursor="hand2",
).pack(side="left", padx=(4, 0))
tk.Button(
toolbar, text="\u2715",
command=lambda: self._close_explanation(generation),
bg=card_bg, fg=fg_secondary,
activebackground=accent_hover, activeforeground=bg,
relief="flat", padx=8, pady=4, font=("Helvetica", 13),
takefocus=0, cursor="hand2",
).grid(row=0, column=3, sticky="e", padx=(12, 0))
# ── Sentence text (source) ────────────────────────────────────────
sentence_card = tk.Frame(frame, bg=card_bg, padx=12, pady=10)
sentence_card.grid(row=1, column=0, sticky="ew", pady=(0, 10))
sentence_card.columnconfigure(0, weight=1)
tk.Label(
sentence_card,
text="当前句子",
bg=card_bg,
fg=fg_secondary,
font=("Helvetica", 10),
).grid(row=0, column=0, sticky="w")
sentence_label = tk.Label(
sentence_card,
text=sentence_text,
wraplength=940,
justify="left",
bg=card_bg,
fg=fg_primary,
font=("Helvetica", font_size[0], "bold"),
)
sentence_label.grid(row=1, column=0, sticky="ew", pady=(4, 0))
# ── Explanation text area ──────────────────────────────────────────
text = tk.Text(
frame,
wrap="word",
relief="flat",
background="#f6f6f6",
foreground="#111111",
insertbackground="#111111",
selectbackground="#3f8efc",
selectforeground="#ffffff",
padx=10,
pady=10,
bg=card_bg,
fg=fg_primary,
insertbackground=fg_primary,
selectbackground=accent,
selectforeground=bg,
padx=14,
pady=12,
font=("Helvetica", font_size[0]),
borderwidth=0,
highlightthickness=0,
)
text.grid(row=1, column=0, sticky="nsew")
text.grid(row=2, column=0, sticky="nsew")
text.insert("1.0", "AI 正在讲解这句话…")
text.configure(state="disabled")
self._explanation_text = text
self._explanation_debug_var = tk.StringVar(
value=f"请求 #{generation};连接 15 秒 / 响应最长 180 秒;日志见“调试日志”。"
value=f'请求 #{generation};连接 15 秒 / 响应最长 180 秒;日志见\u201c调试日志\u201d。'
)
ttk.Label(
frame,
textvariable=self._explanation_debug_var,
wraplength=440,
wraplength=940,
justify="left",
foreground="#666666",
).grid(row=2, column=0, sticky="ew")
ttk.Button(
frame,
text="关闭",
command=lambda: self._close_explanation(generation),
takefocus=0,
).grid(row=3, column=0, sticky="e", pady=(10, 0))
foreground=fg_secondary,
background=bg,
).grid(row=3, column=0, sticky="ew", pady=(8, 0))
window.bind("<Escape>", lambda _e: self._close_explanation(generation))
self._explanation_window = window
window.protocol("WM_DELETE_WINDOW", lambda: self._close_explanation(generation))
@@ -2532,7 +2690,6 @@ class MediaPlayerApp(tk.Tk):
try:
text.configure(state="normal")
text.delete("1.0", "end")
text.configure(foreground="#111111")
text.insert("1.0", explanation)
text.configure(state="disabled")
if error:

View File

@@ -61,6 +61,7 @@ nvidia-smi
```bash
sudo apt update
sudo apt install -y ffmpeg git curl
sudo apt install -y ninja-build
```
### 安装 uv
@@ -89,8 +90,20 @@ source .venv/bin/activate
```bash
uv pip install -e ".[torch-runtime]" --torch-backend=auto
uv pip install sglang-omni
uv pip install ninja
```
> `ninja` 是 FlashInfer JIT 编译 CUDA kernel 所需的构建工具,缺少它会在启动时
> 报 `FileNotFoundError: 'ninja'`。
>
> 必须用 `apt install ninja-build` 安装系统级 ninja(`/usr/bin/ninja`)。
> `uv pip install ninja` 只安装到虚拟环境 `.venv/bin/`,systemd 服务的 PATH
> 不包含它。如果只能用 venv 里的 ninja,在 systemd 服务的 `[Service]` 段加:
>
> ```ini
> Environment=PATH=/opt/moss-transcribe/MOSS-Transcribe-Diarize/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
> ```
### 下载模型
```bash
@@ -111,8 +124,6 @@ sgl-omni serve \
--model-path OpenMOSS-Team/MOSS-Transcribe-Diarize \
--host 127.0.0.1 \
--port 8001 \
--max-running-requests 4 \
--cuda-graph-max-bs 4 \
--mem-fraction-static 0.35
```
@@ -122,9 +133,7 @@ sgl-omni serve \
|------|--------|------|
| `--host` | `127.0.0.1` | 只监听本机,不要暴露公网 |
| `--port` | `8001` | 服务端口(Whisper 用 9000,避免冲突) |
| `--max-running-requests` | `4` | 最大并发请求数;显存充足可调大 |
| `--cuda-graph-max-bs` | `4` | CUDA Graph 批大小上限,与并发数一致 |
| `--mem-fraction-static` | `0.80` | 静态显存分配比例;与其他 GPU 服务共存时调低 |
| `--mem-fraction-static` | 自动 | 静态显存分配比例(作用于所有 SGLang engine stage);与其他 GPU 服务共存时调低 |
> 与 llama-server、Whisper 等其他 GPU 服务共存时,将
> `--mem-fraction-static` 设为 `0.30`–`0.40`(约 7–10 GB),避免 OOM。
@@ -352,8 +361,7 @@ curl http://127.0.0.1:8000/healthz # 确认 moss_configured 为 true
| Speaches (Whisper) | 4 GB | 停掉后释放 |
| **MOSS + llama-server** | ~16 GB / 24 GB | 安全 |
独占 GPU 时可将 `--mem-fraction-static` 调到 `0.80`(约 19 GB),
`--max-running-requests` 调到 `16`,支持更多并发。
独占 GPU 时可将 `--mem-fraction-static` 调到 `0.80`(约 19 GB),支持更多并发。
---

View File

@@ -4,7 +4,7 @@ After=network-online.target
[Service]
Type=simple
User=oraltrainer
User=lsm
WorkingDirectory=/opt/moss-transcribe
Environment=HF_ENDPOINT=https://hf-mirror.com
ExecStart=/opt/moss-transcribe/.venv/bin/vllm serve OpenMOSS-Team/MOSS-Transcribe-Diarize --host 127.0.0.1 --port 8001 --trust-remote-code

View File

@@ -4,15 +4,13 @@ After=network-online.target
[Service]
Type=simple
User=oraltrainer
User=lsm
WorkingDirectory=/opt/moss-transcribe/MOSS-Transcribe-Diarize
Environment=HF_ENDPOINT=https://hf-mirror.com
ExecStart=/opt/moss-transcribe/MOSS-Transcribe-Diarize/.venv/bin/sgl-omni serve \
--model-path OpenMOSS-Team/MOSS-Transcribe-Diarize \
--host 127.0.0.1 \
--port 8001 \
--max-running-requests 4 \
--cuda-graph-max-bs 4 \
--mem-fraction-static 0.35
Restart=on-failure
RestartSec=10

View File

@@ -246,6 +246,59 @@ _ABBREVIATION_RE = re.compile(
r"e\.g\.|i\.e\.|no\.|approx\.|fig\.|inc\.|ltd\.|jr\.|sr\.|u\.s\.|u\.k\.)"
r"(?![A-Za-z0-9])"
)
_SPEAKER_TAG_RE = re.compile(r"\[(S\d+)\]")
def _expand_speaker_segments(
segment: TranscriptionSegment,
) -> List[TranscriptionSegment]:
"""Split a segment at inline ``[S01]`` speaker tags and strip the tags.
MOSS sometimes embeds speaker tags directly in the text (rather than in
the ``speaker`` JSON field). Each tag marks the start of a new speaker
turn, so the text is split there and the tags are removed from the
visible transcription.
"""
text = segment.text
matches = list(_SPEAKER_TAG_RE.finditer(text))
if not matches:
return [segment]
sub_segments: List[TranscriptionSegment] = []
total = len(matches)
duration = segment.end_seconds - segment.start_seconds
for index, match in enumerate(matches):
start_pos = match.end()
if index + 1 < total:
end_pos = matches[index + 1].start()
else:
end_pos = len(text)
piece = text[start_pos:end_pos].strip()
if not piece:
continue
# Allocate time proportionally across sub-segments.
frac_start = index / total
frac_end = (index + 1) / total
sub_start = segment.start_seconds + duration * frac_start
sub_end = segment.start_seconds + duration * frac_end
words = None
if segment.words:
sub_words = [
w
for w in segment.words
if w.start_seconds >= sub_start and w.end_seconds <= sub_end
]
words = sub_words or None
sub_segments.append(
TranscriptionSegment(
start_seconds=sub_start,
end_seconds=sub_end,
text=piece,
speaker=match.group(1),
words=words,
)
)
return sub_segments or [segment]
def split_sentences_at_punctuation(
@@ -257,14 +310,18 @@ def split_sentences_at_punctuation(
across segments and a sentence is closed at periods/question marks. Commas
are also eligible boundaries, but only when the text before the comma has
enough words; otherwise the comma stays with the following text.
Inline ``[S01]`` speaker tags are stripped and each speaker turn always
starts a new sentence, even when no sentence-ending punctuation exists.
"""
sentences: List[TranscriptionSegment] = []
pieces: List[str] = []
start_seconds: Optional[float] = None
last_segment_end: Optional[float] = None
current_speaker: Optional[str] = None
def flush(end_seconds: float) -> None:
nonlocal pieces, start_seconds
nonlocal pieces, start_seconds, current_speaker
text = " ".join(piece for piece in pieces if piece).strip()
if text and start_seconds is not None and end_seconds > start_seconds:
sentences.append(
@@ -272,19 +329,31 @@ def split_sentences_at_punctuation(
start_seconds=start_seconds,
end_seconds=end_seconds,
text=text,
speaker=current_speaker,
)
)
pieces = []
start_seconds = None
current_speaker = None
expanded: List[TranscriptionSegment] = []
for seg in segments:
expanded.extend(_expand_speaker_segments(seg))
for segment in sorted(
segments, key=lambda item: (item.start_seconds, item.end_seconds)
expanded, key=lambda item: (item.start_seconds, item.end_seconds)
):
text = segment.text.strip()
if not text:
continue
# Each speaker-tagged sub-segment always starts a new sentence,
# regardless of whether the speaker actually changed — MOSS emits
# a tag per phrase/line even for the same speaker.
if pieces and segment.speaker is not None:
flush(segment.start_seconds)
if not pieces:
start_seconds = segment.start_seconds
current_speaker = segment.speaker
seg_pieces: List[Tuple[str, Optional[str]]] = []
cursor = 0
for match in _PUNCTUATION_BOUNDARY.finditer(text):
@@ -322,6 +391,7 @@ def split_sentences_at_punctuation(
end_seconds = ends[index] if index < len(ends) else segment.end_seconds
flush(end_seconds)
start_seconds = end_seconds
current_speaker = segment.speaker
if pieces:
flush(last_segment_end if last_segment_end is not None else start_seconds)
return sentences