diff --git a/media_player.py b/media_player.py index cdd8cdd..d087103 100644 --- a/media_player.py +++ b/media_player.py @@ -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("", self._on_space) self.btn_generate_boundaries = ttk.Button( controls, text="生成句边界", @@ -1172,6 +1232,8 @@ class MediaPlayerApp(tk.Tk): ("", lambda _e: self.seek_sentence(1)), ("", lambda _e: self.show_ai_explanation()), ("", lambda _e: self.show_ai_explanation()), + ("", lambda _e: self.show_ai_explanation()), + ("", lambda _e: self.show_ai_explanation()), ("", lambda _e: self.toggle_fullscreen()), ("", lambda _e: self.toggle_fullscreen()), ("", 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,48 +1809,56 @@ class MediaPlayerApp(tk.Tk): ) self._media_hashes[path] = media_hash - self._report_progress(18, "正在检查线上是否已有句边界…") - remote_document = self._fetch_remote_boundaries(media_hash) - if remote_document and remote_document.get("sentences"): - document = remote_document - source = "server" - self._save_cached_boundaries(media_hash, document) + 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) + 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) - audio_path = self._make_audio_upload( - path, - progress_callback=lambda ratio: self._report_progress( - 25 + ratio * 20, - "正在提取音频轨…", - ), - ) - debug_log( - "audio_extraction_ready", - media_hash=media_hash, - audio_bytes=Path(audio_path).stat().st_size, - ) - document = self._generate_remote_boundaries( - audio_path, - progress_callback=lambda ratio: ( - self._report_progress( - 85, - "服务器正在生成句边界…", - indeterminate=True, - ) - if ratio < 0 - else self._report_progress( - 45 + ratio * 35, - f"正在上传音频… {ratio * 100:.0f}%", - ) - ), - ) - self._save_cached_boundaries(media_hash, document) - source = "generated" + 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( + 25 + ratio * 20, + "正在提取音频轨…", + ), + ) + debug_log( + "audio_extraction_ready", + media_hash=media_hash, + audio_bytes=Path(audio_path).stat().st_size, + ) + document = self._generate_remote_boundaries( + audio_path, + progress_callback=lambda ratio: ( + self._report_progress( + 85, + "服务器正在生成句边界…", + indeterminate=True, + ) + if ratio < 0 + else self._report_progress( + 45 + ratio * 35, + f"正在上传音频… {ratio * 100:.0f}%", + ) + ), + ) + document = _split_speaker_tagged_sentences(document) + self._save_cached_boundaries(media_hash, document) + source = "generated" except Exception as exc: error = str(exc) or exc.__class__.__name__ _debug_logger.exception( @@ -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("", 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: diff --git a/moss/README.md b/moss/README.md index 7472963..9afee67 100644 --- a/moss/README.md +++ b/moss/README.md @@ -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),支持更多并发。 --- diff --git a/moss/systemd/moss-transcribe-vllm.service b/moss/systemd/moss-transcribe-vllm.service index 02b01c6..24f2498 100644 --- a/moss/systemd/moss-transcribe-vllm.service +++ b/moss/systemd/moss-transcribe-vllm.service @@ -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 diff --git a/moss/systemd/moss-transcribe.service b/moss/systemd/moss-transcribe.service index a2fb6c7..22da26a 100644 --- a/moss/systemd/moss-transcribe.service +++ b/moss/systemd/moss-transcribe.service @@ -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 diff --git a/sentence_api/transcription.py b/sentence_api/transcription.py index 1969e70..95583b2 100644 --- a/sentence_api/transcription.py +++ b/sentence_api/transcription.py @@ -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