continue fixing
This commit is contained in:
@@ -158,9 +158,13 @@ segment 会自动合并成同一句。若转写服务支持词级时间戳
|
||||
(`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用
|
||||
标点所在单词的时间戳;不支持时自动按文字长度在 segment 内估算,请求失败会回退为
|
||||
不带词级时间戳的普通请求。MOSS 不支持该参数时同样自动回退,不影响转写。
|
||||
小数点和常见缩写(如 `0.4%`、`a.m.`、`p.m.`、`Mr.`、`Dr.`、`U.S.`、`no.`)不会被
|
||||
当作句子结束;`No.` 作为否定回答时仍会正常断句。
|
||||
每句结束时间默认再向后顺延 300ms(`MOSS_END_PADDING_MS`,可在 200–500 之间调整),
|
||||
避免句子末尾发音被切掉;顺延不会越过下一句的开头。顺延后还会用音频检测句子间的
|
||||
停顿,把结束时间拉回到下一句语音真正开始之前,避免偶尔切到下一句的开头。
|
||||
停顿,把结束时间拉回到下一句语音真正开始之前,避免偶尔切到下一句的开头。每句的
|
||||
开始时间也会用同样的音频检测对齐到真实语音起点,避免 Whisper 起始时间戳偏早时
|
||||
上一句的尾部语音混进下一句开头,保证相邻两句不共享音频内容。
|
||||
|
||||
## 3. 部署 API
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -11,6 +11,8 @@ FRAME_SAMPLES = 480
|
||||
# speech_frame_mask bridges gaps of up to 5 frames, so use 6 frames (~180 ms).
|
||||
MIN_PAUSE_FRAMES = 6
|
||||
ONSET_MARGIN_MS = 50
|
||||
START_LOOKBACK_MS = 300
|
||||
START_LOOKAHEAD_MS = 400
|
||||
|
||||
|
||||
class AudioAnalysisError(RuntimeError):
|
||||
@@ -150,6 +152,57 @@ def refine_sentence_end_ms(
|
||||
return padded_end_ms
|
||||
|
||||
|
||||
def refine_sentence_start_ms(
|
||||
samples: np.ndarray,
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
*,
|
||||
raw_start_ms: int,
|
||||
previous_end_ms: int,
|
||||
) -> int:
|
||||
"""Pull a sentence start forward to the actual speech onset.
|
||||
|
||||
Whisper's segment start can be earlier than the real speech onset (the tail
|
||||
of the previous sentence is still audible at the beginning of the next
|
||||
sentence). When the raw start lands inside continuous speech, this looks for
|
||||
the last pause of at least MIN_PAUSE_FRAMES before the next speech run and
|
||||
moves the start to that onset, so adjacent sentences share no audio.
|
||||
"""
|
||||
if raw_start_ms <= previous_end_ms:
|
||||
return max(previous_end_ms, raw_start_ms)
|
||||
frame_ms = 1000 * max(1, int(round(sample_rate * 0.03))) / sample_rate
|
||||
total_ms = samples.size / sample_rate * 1000
|
||||
window_start_ms = max(0, raw_start_ms - START_LOOKBACK_MS)
|
||||
window_end_ms = min(int(total_ms), raw_start_ms + START_LOOKAHEAD_MS)
|
||||
start_sample = int(window_start_ms / 1000 * sample_rate)
|
||||
end_sample = min(samples.size, int(window_end_ms / 1000 * sample_rate))
|
||||
if end_sample - start_sample < max(1, int(round(sample_rate * 0.03))):
|
||||
return raw_start_ms
|
||||
try:
|
||||
speech = speech_frame_mask(samples[start_sample:end_sample], sample_rate)
|
||||
except AudioAnalysisError:
|
||||
return raw_start_ms
|
||||
onset_frame = int(round((raw_start_ms - window_start_ms) / frame_ms))
|
||||
if (
|
||||
0 <= onset_frame < len(speech)
|
||||
and speech[onset_frame]
|
||||
and (onset_frame == 0 or not speech[onset_frame - 1])
|
||||
):
|
||||
return raw_start_ms # already at a speech onset
|
||||
boundary_ms: Optional[float] = None
|
||||
silence_frames = 0
|
||||
for index, is_speech in enumerate(speech):
|
||||
if not is_speech:
|
||||
silence_frames += 1
|
||||
continue
|
||||
if silence_frames >= MIN_PAUSE_FRAMES:
|
||||
boundary_ms = window_start_ms + index * frame_ms
|
||||
silence_frames = 0
|
||||
if boundary_ms is None:
|
||||
return raw_start_ms
|
||||
boundary_ms = min(boundary_ms, raw_start_ms + START_LOOKAHEAD_MS)
|
||||
return max(previous_end_ms, int(boundary_ms))
|
||||
|
||||
|
||||
def _bridge_false_runs(values: np.ndarray, max_frames: int) -> None:
|
||||
start = None
|
||||
for index, value in enumerate(values):
|
||||
|
||||
@@ -9,6 +9,7 @@ from .audio_metrics import (
|
||||
analyze_samples,
|
||||
decode_audio_mono,
|
||||
refine_sentence_end_ms,
|
||||
refine_sentence_start_ms,
|
||||
)
|
||||
from .config import Settings
|
||||
from .generate_boundaries import ALGORITHM_VERSION, make_entry
|
||||
@@ -116,7 +117,8 @@ def document_from_transcript(
|
||||
previous_end = 0
|
||||
sentence_segments = split_sentences_at_punctuation(transcript.segments)
|
||||
for index, sentence_segment in enumerate(sentence_segments):
|
||||
start_ms = max(previous_end, int(round(sentence_segment.start_seconds * 1000)))
|
||||
raw_start_ms = int(round(sentence_segment.start_seconds * 1000))
|
||||
start_ms = max(previous_end, raw_start_ms)
|
||||
raw_end_ms = int(round(sentence_segment.end_seconds * 1000))
|
||||
end_ms = min(
|
||||
duration_ms,
|
||||
@@ -130,6 +132,14 @@ def document_from_transcript(
|
||||
end_ms = refine_sentence_end_ms(
|
||||
samples, sample_rate, raw_end_ms=raw_end_ms, padded_end_ms=end_ms
|
||||
)
|
||||
refined_start_ms = refine_sentence_start_ms(
|
||||
samples,
|
||||
sample_rate,
|
||||
raw_start_ms=raw_start_ms,
|
||||
previous_end_ms=previous_end,
|
||||
)
|
||||
if previous_end <= refined_start_ms and end_ms - refined_start_ms >= 100:
|
||||
start_ms = refined_start_ms
|
||||
if not sentence_segment.text.strip() or end_ms <= start_ms:
|
||||
continue
|
||||
start_sample = max(0, int(start_ms / 1000 * sample_rate))
|
||||
|
||||
@@ -258,11 +258,13 @@ async function deleteVideo(video) {
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (!ms) return "-";
|
||||
const total = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor(total % 3600 / 60);
|
||||
const seconds = total % 60;
|
||||
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` : `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
const totalTenths = Math.floor(ms / 100);
|
||||
const hours = Math.floor(totalTenths / 36000);
|
||||
const minutes = Math.floor(totalTenths % 36000 / 600);
|
||||
const seconds = Math.floor(totalTenths % 600 / 10);
|
||||
const tenths = totalTenths % 10;
|
||||
const base = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${tenths}`;
|
||||
return hours ? `${hours}:${base}` : base;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
|
||||
@@ -351,6 +351,83 @@ def test_split_sentences_handles_ellipsis_and_dots_only():
|
||||
) == []
|
||||
|
||||
|
||||
def test_split_sentences_ignores_decimal_points():
|
||||
segment = TranscriptionSegment(
|
||||
start_seconds=0.0,
|
||||
end_seconds=6.0,
|
||||
text="The rate rose by 0.4%. That is a big jump.",
|
||||
)
|
||||
sentences = split_sentences_at_punctuation([segment])
|
||||
assert [s.text for s in sentences] == [
|
||||
"The rate rose by 0.4%.",
|
||||
"That is a big jump.",
|
||||
]
|
||||
|
||||
|
||||
def test_split_sentences_ignores_am_pm_abbreviations():
|
||||
segment = TranscriptionSegment(
|
||||
start_seconds=0.0,
|
||||
end_seconds=8.0,
|
||||
text=(
|
||||
"It was at 9 o'clock p.m. Eastern, President Trump was speaking. "
|
||||
"The room was quiet."
|
||||
),
|
||||
)
|
||||
sentences = split_sentences_at_punctuation([segment])
|
||||
assert [s.text for s in sentences] == [
|
||||
"It was at 9 o'clock p.m. Eastern, President Trump was speaking.",
|
||||
"The room was quiet.",
|
||||
]
|
||||
|
||||
morning = TranscriptionSegment(
|
||||
start_seconds=0.0,
|
||||
end_seconds=4.0,
|
||||
text="I woke up at 7 a.m. and had coffee. Then I left.",
|
||||
)
|
||||
sentences = split_sentences_at_punctuation([morning])
|
||||
assert [s.text for s in sentences] == [
|
||||
"I woke up at 7 a.m. and had coffee.",
|
||||
"Then I left.",
|
||||
]
|
||||
|
||||
|
||||
def test_split_sentences_merges_segment_ending_with_abbreviation():
|
||||
segments = [
|
||||
TranscriptionSegment(0.0, 5.0, "The meeting ends at 5 p.m."),
|
||||
TranscriptionSegment(5.0, 9.0, "Eastern time. Then we go home."),
|
||||
]
|
||||
sentences = split_sentences_at_punctuation(segments)
|
||||
assert [s.text for s in sentences] == [
|
||||
"The meeting ends at 5 p.m. Eastern time.",
|
||||
"Then we go home.",
|
||||
]
|
||||
|
||||
|
||||
def test_split_sentences_distinguishes_no_reply_from_number():
|
||||
reply = TranscriptionSegment(0.0, 4.0, "No. I don't think so.")
|
||||
assert [s.text for s in split_sentences_at_punctuation([reply])] == [
|
||||
"No.",
|
||||
"I don't think so.",
|
||||
]
|
||||
numbered = TranscriptionSegment(0.0, 4.0, "See no. 5 on the list.")
|
||||
assert [s.text for s in split_sentences_at_punctuation([numbered])] == [
|
||||
"See no. 5 on the list."
|
||||
]
|
||||
|
||||
|
||||
def test_split_sentences_ignores_common_abbreviations():
|
||||
segment = TranscriptionSegment(
|
||||
start_seconds=0.0,
|
||||
end_seconds=4.0,
|
||||
text="Dr. Smith said the U.S. economy is growing. He was right.",
|
||||
)
|
||||
sentences = split_sentences_at_punctuation([segment])
|
||||
assert [s.text for s in sentences] == [
|
||||
"Dr. Smith said the U.S. economy is growing.",
|
||||
"He was right.",
|
||||
]
|
||||
|
||||
|
||||
def test_post_audio_requests_word_timestamps_and_falls_back(monkeypatch):
|
||||
calls = []
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Protocol
|
||||
from typing import List, Optional, Protocol, Tuple
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
@@ -240,6 +240,11 @@ def _parse_word_timestamps(raw_words) -> Optional[List[WordTimestamp]]:
|
||||
|
||||
|
||||
_PUNCTUATION_BOUNDARY = re.compile(r"[.?]+")
|
||||
_ABBREVIATION_RE = re.compile(
|
||||
r"(?i)\b(?:a\.m\.|p\.m\.|mr\.|mrs\.|ms\.|dr\.|prof\.|st\.|vs\.|etc\.|"
|
||||
r"e\.g\.|i\.e\.|no\.|approx\.|fig\.|inc\.|ltd\.|jr\.|sr\.|u\.s\.|u\.k\.)"
|
||||
r"(?![A-Za-z0-9])"
|
||||
)
|
||||
|
||||
|
||||
def split_sentences_at_punctuation(
|
||||
@@ -278,19 +283,21 @@ def split_sentences_at_punctuation(
|
||||
continue
|
||||
if not pieces:
|
||||
start_seconds = segment.start_seconds
|
||||
seg_pieces: List[str] = []
|
||||
seg_pieces: List[Tuple[str, bool]] = []
|
||||
cursor = 0
|
||||
for match in _PUNCTUATION_BOUNDARY.finditer(text):
|
||||
if not _is_sentence_end_run(text, match.start(), match.end()):
|
||||
continue
|
||||
piece = text[cursor:match.end()].strip()
|
||||
cursor = match.end()
|
||||
if piece.strip(".?").strip():
|
||||
seg_pieces.append(piece)
|
||||
seg_pieces.append((piece, True))
|
||||
tail = text[cursor:].strip()
|
||||
if tail:
|
||||
seg_pieces.append(tail)
|
||||
seg_pieces.append((tail, False))
|
||||
if not seg_pieces:
|
||||
continue
|
||||
lengths = [len(piece) for piece in seg_pieces]
|
||||
lengths = [len(piece) for piece, _ in seg_pieces]
|
||||
ends: List[float] = []
|
||||
if segment.words and len(segment.words) >= len(seg_pieces):
|
||||
ends = _word_boundary_ends(segment, lengths)
|
||||
@@ -299,9 +306,9 @@ def split_sentences_at_punctuation(
|
||||
if not ends:
|
||||
ends = _proportional_ends(segment, lengths)
|
||||
last_segment_end = segment.end_seconds
|
||||
for index, piece in enumerate(seg_pieces):
|
||||
for index, (piece, is_boundary) in enumerate(seg_pieces):
|
||||
pieces.append(piece)
|
||||
if piece[-1] in ".?":
|
||||
if is_boundary:
|
||||
end_seconds = ends[index] if index < len(ends) else segment.end_seconds
|
||||
flush(end_seconds)
|
||||
start_seconds = end_seconds
|
||||
@@ -310,6 +317,40 @@ def split_sentences_at_punctuation(
|
||||
return sentences
|
||||
|
||||
|
||||
def _is_sentence_end_run(text: str, run_start: int, run_end: int) -> bool:
|
||||
"""Decide whether a "." / "?" run really ends a sentence."""
|
||||
punctuation = text[run_start:run_end]
|
||||
if "?" in punctuation:
|
||||
return True
|
||||
if (
|
||||
run_start > 0
|
||||
and run_end < len(text)
|
||||
and text[run_start - 1].isdigit()
|
||||
and text[run_end].isdigit()
|
||||
):
|
||||
return False # decimal point, e.g. "0.4"
|
||||
if len(punctuation) > 1:
|
||||
return True # ellipsis or "!?" etc.
|
||||
if _is_abbreviation_run(text, run_start, run_end):
|
||||
return False # e.g. "a.m.", "p.m.", "Mr."
|
||||
rest = text[run_end:].lstrip()
|
||||
if rest and rest[0].islower():
|
||||
return False # period followed by a lowercase word is an abbreviation
|
||||
return True
|
||||
|
||||
|
||||
def _is_abbreviation_run(text: str, run_start: int, run_end: int) -> bool:
|
||||
for match in _ABBREVIATION_RE.finditer(text):
|
||||
if not (match.start() < run_end <= match.end()):
|
||||
continue
|
||||
if match.group(0).lower() == "no.":
|
||||
rest = text[match.end():].lstrip()
|
||||
if not rest or not rest[0].isdigit():
|
||||
return False # "No." as a reply ends the sentence
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _word_boundary_ends(
|
||||
segment: TranscriptionSegment, lengths: List[int]
|
||||
) -> List[float]:
|
||||
|
||||
Reference in New Issue
Block a user