optimized PC player
This commit is contained in:
299
media_player.py
299
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("<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,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("<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:
|
||||
|
||||
@@ -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),支持更多并发。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user