diff --git a/sentence_api/DEPLOYMENT.md b/sentence_api/DEPLOYMENT.md index 38077cf..85d1963 100644 --- a/sentence_api/DEPLOYMENT.md +++ b/sentence_api/DEPLOYMENT.md @@ -159,7 +159,8 @@ segment 会自动合并成同一句。若转写服务支持词级时间戳 标点所在单词的时间戳;不支持时自动按文字长度在 segment 内估算,请求失败会回退为 不带词级时间戳的普通请求。MOSS 不支持该参数时同样自动回退,不影响转写。 每句结束时间默认再向后顺延 300ms(`MOSS_END_PADDING_MS`,可在 200–500 之间调整), -避免句子末尾发音被切掉;顺延不会越过下一句的开头。 +避免句子末尾发音被切掉;顺延不会越过下一句的开头。顺延后还会用音频检测句子间的 +停顿,把结束时间拉回到下一句语音真正开始之前,避免偶尔切到下一句的开头。 ## 3. 部署 API diff --git a/sentence_api/audio_metrics.py b/sentence_api/audio_metrics.py index d9b8883..f2cf9f2 100644 --- a/sentence_api/audio_metrics.py +++ b/sentence_api/audio_metrics.py @@ -7,6 +7,10 @@ import numpy as np SAMPLE_RATE = 16_000 FRAME_SAMPLES = 480 +# A silence run at least this long (in 30 ms frames) separates two sentences. +# speech_frame_mask bridges gaps of up to 5 frames, so use 6 frames (~180 ms). +MIN_PAUSE_FRAMES = 6 +ONSET_MARGIN_MS = 50 class AudioAnalysisError(RuntimeError): @@ -108,6 +112,44 @@ def speech_frame_mask(samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> np return speech +def refine_sentence_end_ms( + samples: np.ndarray, + sample_rate: int = SAMPLE_RATE, + *, + raw_end_ms: int, + padded_end_ms: int, +) -> int: + """Pull a padded sentence end back to just before the next sentence's speech. + + Whisper's timestamps are not always aligned with the real audio: the next + segment's start can be later than the actual speech onset, so a fixed + end-padding may occasionally run into the next sentence's beginning. This + finds the first silence run of at least MIN_PAUSE_FRAMES inside the padded + region and stops the sentence just before the speech that follows it. + """ + if padded_end_ms <= raw_end_ms: + return padded_end_ms + frame_ms = 1000 * max(1, int(round(sample_rate * 0.03))) / sample_rate + start_sample = max(0, int(raw_end_ms / 1000 * sample_rate)) + end_sample = min(samples.size, int(padded_end_ms / 1000 * sample_rate)) + if end_sample <= start_sample: + return padded_end_ms + try: + speech = speech_frame_mask(samples[start_sample:end_sample], sample_rate) + except AudioAnalysisError: + return padded_end_ms + silence_frames = 0 + for index, is_speech in enumerate(speech): + if not is_speech: + silence_frames += 1 + continue + if silence_frames >= MIN_PAUSE_FRAMES: + onset_ms = int(raw_end_ms + index * frame_ms) + return max(raw_end_ms, min(padded_end_ms, onset_ms - ONSET_MARGIN_MS)) + silence_frames = 0 + return padded_end_ms + + def _bridge_false_runs(values: np.ndarray, max_frames: int) -> None: start = None for index, value in enumerate(values): diff --git a/sentence_api/processing.py b/sentence_api/processing.py index 3b83c38..6c7f7c8 100644 --- a/sentence_api/processing.py +++ b/sentence_api/processing.py @@ -4,7 +4,12 @@ import uuid from pathlib import Path from typing import List, Optional -from .audio_metrics import AudioAnalysisError, analyze_samples, decode_audio_mono +from .audio_metrics import ( + AudioAnalysisError, + analyze_samples, + decode_audio_mono, + refine_sentence_end_ms, +) from .config import Settings from .generate_boundaries import ALGORITHM_VERSION, make_entry from .models import SentenceBoundary, SentenceBoundaryDocument @@ -112,15 +117,19 @@ def document_from_transcript( 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_end_ms = int(round(sentence_segment.end_seconds * 1000)) end_ms = min( duration_ms, - int(round(sentence_segment.end_seconds * 1000)) + end_padding_ms, + raw_end_ms + end_padding_ms, ) if index + 1 < len(sentence_segments): next_start_ms = int( round(sentence_segments[index + 1].start_seconds * 1000) ) end_ms = min(end_ms, next_start_ms) + end_ms = refine_sentence_end_ms( + samples, sample_rate, raw_end_ms=raw_end_ms, padded_end_ms=end_ms + ) if not sentence_segment.text.strip() or end_ms <= start_ms: continue start_sample = max(0, int(start_ms / 1000 * sample_rate))