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 import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
import os import os
import re
import queue import queue
import sys import sys
import tempfile import tempfile
@@ -86,6 +87,61 @@ DEBUG_LOG_DIR = Path.home() / ".mediaplayer_logs"
DEBUG_LOG_PATH = DEBUG_LOG_DIR / "mediaplayer.log" DEBUG_LOG_PATH = DEBUG_LOG_DIR / "mediaplayer.log"
_debug_logger = logging.getLogger("mediaplayer") _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: def setup_debug_logging() -> Path:
"""Write concise diagnostics to a small local rotating log.""" """Write concise diagnostics to a small local rotating log."""
@@ -1103,8 +1159,12 @@ class MediaPlayerApp(tk.Tk):
text="按句跳转", text="按句跳转",
variable=self.sentence_mode, variable=self.sentence_mode,
command=self._on_sentence_mode, command=self._on_sentence_mode,
takefocus=0,
) )
self.sentence_mode_btn.pack(side="left", padx=(16, 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( self.btn_generate_boundaries = ttk.Button(
controls, controls,
text="生成句边界", text="生成句边界",
@@ -1172,6 +1232,8 @@ class MediaPlayerApp(tk.Tk):
("<period>", 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()),
("<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()),
("<F>", lambda _e: self.toggle_fullscreen()), ("<F>", lambda _e: self.toggle_fullscreen()),
("<Escape>", lambda _e: self.leave_fullscreen()), ("<Escape>", lambda _e: self.leave_fullscreen()),
@@ -1622,7 +1684,7 @@ class MediaPlayerApp(tk.Tk):
document = self._load_cached_boundaries(media_hash) document = self._load_cached_boundaries(media_hash)
if document and document.get("sentences"): if document and document.get("sentences"):
self._media_hashes[path] = media_hash self._media_hashes[path] = media_hash
self._boundaries[path] = document self._boundaries[path] = _split_speaker_tagged_sentences(document)
count = len(document["sentences"]) count = len(document["sentences"])
message = f"已加载本地句边界缓存:共 {count} 句" message = f"已加载本地句边界缓存:共 {count} 句"
debug_log( debug_log(
@@ -1714,6 +1776,9 @@ class MediaPlayerApp(tk.Tk):
if (self._boundaries.get(path) or {}).get("sentences"): if (self._boundaries.get(path) or {}).get("sentences"):
if not messagebox.askyesno("生成句边界", "当前文件已有句边界,确定要重新生成吗?"): if not messagebox.askyesno("生成句边界", "当前文件已有句边界,确定要重新生成吗?"):
return return
force = True
else:
force = False
self.sentence_mode.set(False) self.sentence_mode.set(False)
self._save_settings() self._save_settings()
self._analysis_running.add(path) self._analysis_running.add(path)
@@ -1723,11 +1788,11 @@ class MediaPlayerApp(tk.Tk):
self._update_sentence_controls() self._update_sentence_controls()
threading.Thread( threading.Thread(
target=self._analyze_sentences, target=self._analyze_sentences,
args=(path, token), args=(path, token, force),
daemon=True, daemon=True,
).start() ).start()
def _analyze_sentences(self, path, token): def _analyze_sentences(self, path, token, force=False):
error = "" error = ""
document = None document = None
source = "generated" source = "generated"
@@ -1744,48 +1809,56 @@ class MediaPlayerApp(tk.Tk):
) )
self._media_hashes[path] = media_hash self._media_hashes[path] = media_hash
self._report_progress(18, "正在检查线上是否已有句边界…") if force:
remote_document = self._fetch_remote_boundaries(media_hash) self._report_progress(18, "正在重新生成句边界…")
if remote_document and remote_document.get("sentences"):
document = remote_document
source = "server"
self._save_cached_boundaries(media_hash, document)
else: 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) document = self._load_cached_boundaries(media_hash)
if document and document.get("sentences"): if document and document.get("sentences"):
source = "local" source = "local"
else: if document is None:
if not force:
self._report_progress(25, "线上没有句边界,正在准备音频…") self._report_progress(25, "线上没有句边界,正在准备音频…")
debug_log("audio_extraction_started", media_hash=media_hash) else:
audio_path = self._make_audio_upload( self._report_progress(25, "正在重新提取音频…")
path, debug_log("audio_extraction_started", media_hash=media_hash, force=force)
progress_callback=lambda ratio: self._report_progress( audio_path = self._make_audio_upload(
25 + ratio * 20, path,
"正在提取音频轨…", progress_callback=lambda ratio: self._report_progress(
), 25 + ratio * 20,
) "正在提取音频轨…",
debug_log( ),
"audio_extraction_ready", )
media_hash=media_hash, debug_log(
audio_bytes=Path(audio_path).stat().st_size, "audio_extraction_ready",
) media_hash=media_hash,
document = self._generate_remote_boundaries( audio_bytes=Path(audio_path).stat().st_size,
audio_path, )
progress_callback=lambda ratio: ( document = self._generate_remote_boundaries(
self._report_progress( audio_path,
85, progress_callback=lambda ratio: (
"服务器正在生成句边界…", self._report_progress(
indeterminate=True, 85,
) "服务器正在生成句边界…",
if ratio < 0 indeterminate=True,
else self._report_progress( )
45 + ratio * 35, if ratio < 0
f"正在上传音频… {ratio * 100:.0f}%", else self._report_progress(
) 45 + ratio * 35,
), f"正在上传音频… {ratio * 100:.0f}%",
) )
self._save_cached_boundaries(media_hash, document) ),
source = "generated" )
document = _split_speaker_tagged_sentences(document)
self._save_cached_boundaries(media_hash, document)
source = "generated"
except Exception as exc: except Exception as exc:
error = str(exc) or exc.__class__.__name__ error = str(exc) or exc.__class__.__name__
_debug_logger.exception( _debug_logger.exception(
@@ -1800,6 +1873,7 @@ class MediaPlayerApp(tk.Tk):
self._analysis_running.discard(path) self._analysis_running.discard(path)
if document is not None and document.get("sentences"): if document is not None and document.get("sentences"):
document = _split_speaker_tagged_sentences(document)
self._boundaries[path] = document self._boundaries[path] = document
count = len(document["sentences"]) count = len(document["sentences"])
if source == "server": if source == "server":
@@ -2367,54 +2441,138 @@ class MediaPlayerApp(tk.Tk):
pass pass
window = tk.Toplevel(self) window = tk.Toplevel(self)
window.title("AI 句子讲解") window.title("AI 句子讲解")
window.geometry("480x380") window.geometry("1000x800")
window.minsize(1000, 800)
window.configure(background="#1e1e2e")
window.transient(self) 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.pack(fill="both", expand=True)
frame.columnconfigure(0, weight=1) frame.columnconfigure(0, weight=1)
frame.rowconfigure(1, weight=1) frame.rowconfigure(2, weight=1)
ttk.Label(
frame, font_size = [20]
text=sentence_text,
wraplength=440, # ── Top toolbar panel ─────────────────────────────────────────────
justify="left", # Fixed-height, always visible: title | A− 20pt A+ | ✕
foreground="#111111", toolbar = tk.Frame(frame, bg=card_bg, padx=12, pady=8)
font=("Helvetica", 12, "bold"), toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 10))
).grid( toolbar.columnconfigure(1, weight=1)
row=0, column=0, sticky="ew", pady=(0, 10)
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( text = tk.Text(
frame, frame,
wrap="word", wrap="word",
relief="flat", relief="flat",
background="#f6f6f6", bg=card_bg,
foreground="#111111", fg=fg_primary,
insertbackground="#111111", insertbackground=fg_primary,
selectbackground="#3f8efc", selectbackground=accent,
selectforeground="#ffffff", selectforeground=bg,
padx=10, padx=14,
pady=10, 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.insert("1.0", "AI 正在讲解这句话…")
text.configure(state="disabled") text.configure(state="disabled")
self._explanation_text = text self._explanation_text = text
self._explanation_debug_var = tk.StringVar( self._explanation_debug_var = tk.StringVar(
value=f"请求 #{generation};连接 15 秒 / 响应最长 180 秒;日志见“调试日志”。" value=f'请求 #{generation};连接 15 秒 / 响应最长 180 秒;日志见\u201c调试日志\u201d。'
) )
ttk.Label( ttk.Label(
frame, frame,
textvariable=self._explanation_debug_var, textvariable=self._explanation_debug_var,
wraplength=440, wraplength=940,
justify="left", justify="left",
foreground="#666666", foreground=fg_secondary,
).grid(row=2, column=0, sticky="ew") background=bg,
ttk.Button( ).grid(row=3, column=0, sticky="ew", pady=(8, 0))
frame,
text="关闭", window.bind("<Escape>", lambda _e: self._close_explanation(generation))
command=lambda: self._close_explanation(generation),
takefocus=0,
).grid(row=3, column=0, sticky="e", pady=(10, 0))
self._explanation_window = window self._explanation_window = window
window.protocol("WM_DELETE_WINDOW", lambda: self._close_explanation(generation)) window.protocol("WM_DELETE_WINDOW", lambda: self._close_explanation(generation))
@@ -2532,7 +2690,6 @@ class MediaPlayerApp(tk.Tk):
try: try:
text.configure(state="normal") text.configure(state="normal")
text.delete("1.0", "end") text.delete("1.0", "end")
text.configure(foreground="#111111")
text.insert("1.0", explanation) text.insert("1.0", explanation)
text.configure(state="disabled") text.configure(state="disabled")
if error: if error:

View File

@@ -61,6 +61,7 @@ nvidia-smi
```bash ```bash
sudo apt update sudo apt update
sudo apt install -y ffmpeg git curl sudo apt install -y ffmpeg git curl
sudo apt install -y ninja-build
``` ```
### 安装 uv ### 安装 uv
@@ -89,8 +90,20 @@ source .venv/bin/activate
```bash ```bash
uv pip install -e ".[torch-runtime]" --torch-backend=auto uv pip install -e ".[torch-runtime]" --torch-backend=auto
uv pip install sglang-omni 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 ```bash
@@ -111,8 +124,6 @@ sgl-omni serve \
--model-path OpenMOSS-Team/MOSS-Transcribe-Diarize \ --model-path OpenMOSS-Team/MOSS-Transcribe-Diarize \
--host 127.0.0.1 \ --host 127.0.0.1 \
--port 8001 \ --port 8001 \
--max-running-requests 4 \
--cuda-graph-max-bs 4 \
--mem-fraction-static 0.35 --mem-fraction-static 0.35
``` ```
@@ -122,9 +133,7 @@ sgl-omni serve \
|------|--------|------| |------|--------|------|
| `--host` | `127.0.0.1` | 只监听本机,不要暴露公网 | | `--host` | `127.0.0.1` | 只监听本机,不要暴露公网 |
| `--port` | `8001` | 服务端口(Whisper 用 9000,避免冲突) | | `--port` | `8001` | 服务端口(Whisper 用 9000,避免冲突) |
| `--max-running-requests` | `4` | 最大并发请求数;显存充足可调大 | | `--mem-fraction-static` | 自动 | 静态显存分配比例(作用于所有 SGLang engine stage);与其他 GPU 服务共存时调低 |
| `--cuda-graph-max-bs` | `4` | CUDA Graph 批大小上限,与并发数一致 |
| `--mem-fraction-static` | `0.80` | 静态显存分配比例;与其他 GPU 服务共存时调低 |
> 与 llama-server、Whisper 等其他 GPU 服务共存时,将 > 与 llama-server、Whisper 等其他 GPU 服务共存时,将
> `--mem-fraction-static` 设为 `0.30`–`0.40`(约 7–10 GB),避免 OOM。 > `--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 | 停掉后释放 | | Speaches (Whisper) | 4 GB | 停掉后释放 |
| **MOSS + llama-server** | ~16 GB / 24 GB | 安全 | | **MOSS + llama-server** | ~16 GB / 24 GB | 安全 |
独占 GPU 时可将 `--mem-fraction-static` 调到 `0.80`(约 19 GB), 独占 GPU 时可将 `--mem-fraction-static` 调到 `0.80`(约 19 GB),支持更多并发。
`--max-running-requests` 调到 `16`,支持更多并发。
--- ---

View File

@@ -4,7 +4,7 @@ After=network-online.target
[Service] [Service]
Type=simple Type=simple
User=oraltrainer User=lsm
WorkingDirectory=/opt/moss-transcribe WorkingDirectory=/opt/moss-transcribe
Environment=HF_ENDPOINT=https://hf-mirror.com 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 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] [Service]
Type=simple Type=simple
User=oraltrainer User=lsm
WorkingDirectory=/opt/moss-transcribe/MOSS-Transcribe-Diarize WorkingDirectory=/opt/moss-transcribe/MOSS-Transcribe-Diarize
Environment=HF_ENDPOINT=https://hf-mirror.com Environment=HF_ENDPOINT=https://hf-mirror.com
ExecStart=/opt/moss-transcribe/MOSS-Transcribe-Diarize/.venv/bin/sgl-omni serve \ ExecStart=/opt/moss-transcribe/MOSS-Transcribe-Diarize/.venv/bin/sgl-omni serve \
--model-path OpenMOSS-Team/MOSS-Transcribe-Diarize \ --model-path OpenMOSS-Team/MOSS-Transcribe-Diarize \
--host 127.0.0.1 \ --host 127.0.0.1 \
--port 8001 \ --port 8001 \
--max-running-requests 4 \
--cuda-graph-max-bs 4 \
--mem-fraction-static 0.35 --mem-fraction-static 0.35
Restart=on-failure Restart=on-failure
RestartSec=10 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"e\.g\.|i\.e\.|no\.|approx\.|fig\.|inc\.|ltd\.|jr\.|sr\.|u\.s\.|u\.k\.)"
r"(?![A-Za-z0-9])" 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( 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 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 are also eligible boundaries, but only when the text before the comma has
enough words; otherwise the comma stays with the following text. 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] = [] sentences: List[TranscriptionSegment] = []
pieces: List[str] = [] pieces: List[str] = []
start_seconds: Optional[float] = None start_seconds: Optional[float] = None
last_segment_end: Optional[float] = None last_segment_end: Optional[float] = None
current_speaker: Optional[str] = None
def flush(end_seconds: float) -> 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() text = " ".join(piece for piece in pieces if piece).strip()
if text and start_seconds is not None and end_seconds > start_seconds: if text and start_seconds is not None and end_seconds > start_seconds:
sentences.append( sentences.append(
@@ -272,19 +329,31 @@ def split_sentences_at_punctuation(
start_seconds=start_seconds, start_seconds=start_seconds,
end_seconds=end_seconds, end_seconds=end_seconds,
text=text, text=text,
speaker=current_speaker,
) )
) )
pieces = [] pieces = []
start_seconds = None start_seconds = None
current_speaker = None
expanded: List[TranscriptionSegment] = []
for seg in segments:
expanded.extend(_expand_speaker_segments(seg))
for segment in sorted( 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() text = segment.text.strip()
if not text: if not text:
continue 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: if not pieces:
start_seconds = segment.start_seconds start_seconds = segment.start_seconds
current_speaker = segment.speaker
seg_pieces: List[Tuple[str, Optional[str]]] = [] seg_pieces: List[Tuple[str, Optional[str]]] = []
cursor = 0 cursor = 0
for match in _PUNCTUATION_BOUNDARY.finditer(text): 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 end_seconds = ends[index] if index < len(ends) else segment.end_seconds
flush(end_seconds) flush(end_seconds)
start_seconds = end_seconds start_seconds = end_seconds
current_speaker = segment.speaker
if pieces: if pieces:
flush(last_segment_end if last_segment_end is not None else start_seconds) flush(last_segment_end if last_segment_end is not None else start_seconds)
return sentences return sentences