Lets users insert pauses in the transcript: `[pause]` (350ms default), `[pause 500ms]`, `[pause 1s]`, `[pause 1.5s]`. Requester confirmed the `[pause Nms]` syntax (fits the existing marker style). Implementation is fully opt-in and model-free: - `omnivoice/utils/text.parse_pause_markers()` splits the text into `(span, pause_ms_after)` tuples (case-insensitive; bare number = ms; `s` suffix = seconds; adjacent markers sum; clamped to 10s). Text with no marker returns unchanged, so existing behavior is untouched. - `_run_inference` synthesizes each span as today and stitches a `torch.zeros` silence buffer between them at the `[pause]` points (matching channel dims/dtype/device); DSP/mastering then runs once over the combined audio. An explicit overall `duration` isn't split across spans (left to the model per span). Tests (no TTS model loaded): tests/test_pause_markers.py covers the parser (ms/s/default/clamp/leading/trailing/adjacent/round-trip) and the silence stitching with a fake gen fn (lengths + zeroed regions). Full pause + CJK guard + router smoke suites pass (39). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c427ffa62d
commit
c5ba10b20a
@@ -20,6 +20,45 @@ from core import event_bus
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.generate")
|
||||
|
||||
|
||||
def _render_with_pauses(gen_span, segments, sample_rate):
|
||||
"""Synthesize ``[(text, pause_ms), ...]`` spans and stitch silence between
|
||||
them (issue #276).
|
||||
|
||||
``gen_span(text) -> torch.Tensor`` synthesizes one text span (raw model
|
||||
output). A silence buffer of ``pause_ms`` is inserted after a span when
|
||||
requested, matching the audio tensor's channel dims / dtype / device.
|
||||
Returns the concatenated waveform. Kept model-free (``gen_span`` is injected)
|
||||
so the stitching is unit-testable without loading the TTS model.
|
||||
"""
|
||||
import torch
|
||||
|
||||
items = [] # ('a', tensor) for audio, ('s', n_samples) for silence
|
||||
for span_text, pause_ms in segments:
|
||||
if span_text and span_text.strip():
|
||||
items.append(("a", gen_span(span_text)))
|
||||
if pause_ms > 0:
|
||||
n = int(round(sample_rate * pause_ms / 1000.0))
|
||||
if n > 0:
|
||||
items.append(("s", n))
|
||||
|
||||
ref = next((t for kind, t in items if kind == "a"), None)
|
||||
if ref is None:
|
||||
# No speakable text (e.g. the input was only pause markers) — emit the
|
||||
# requested silence so the caller still gets a valid clip.
|
||||
total = sum(n for kind, n in items if kind == "s") or 1
|
||||
return torch.zeros(total, dtype=torch.float32)
|
||||
|
||||
parts = []
|
||||
for kind, val in items:
|
||||
if kind == "a":
|
||||
parts.append(val)
|
||||
else:
|
||||
shape = list(ref.shape)
|
||||
shape[-1] = val
|
||||
parts.append(torch.zeros(*shape, dtype=ref.dtype, device=ref.device))
|
||||
return torch.cat(parts, dim=-1)
|
||||
|
||||
def _run_inference(
|
||||
model, text, language, ref_audio_path, ref_text, instruct, duration,
|
||||
num_step, guidance_scale, speed, t_shift, denoise,
|
||||
@@ -38,17 +77,37 @@ def _run_inference(
|
||||
if position_temperature is not None: kwargs["position_temperature"] = position_temperature
|
||||
if class_temperature is not None: kwargs["class_temperature"] = class_temperature
|
||||
|
||||
audios = model.generate(
|
||||
text=text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=duration,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)
|
||||
audio_out = audios[0]
|
||||
|
||||
sr = model.sampling_rate if hasattr(model, 'sampling_rate') else 24000
|
||||
|
||||
# Inline [pause Nms] markers (issue #276): split the text and stitch
|
||||
# silence between independently-synthesized spans. Fully opt-in — text
|
||||
# without a marker takes the unchanged single-shot path below.
|
||||
from omnivoice.utils.text import parse_pause_markers
|
||||
segments = parse_pause_markers(text)
|
||||
has_pause = len(segments) > 1 or (segments and segments[0][1] > 0)
|
||||
|
||||
if has_pause:
|
||||
def _gen_span(span_text):
|
||||
# Per-span duration is left to the model; an explicit overall
|
||||
# `duration` can't be meaningfully split across spans.
|
||||
return model.generate(
|
||||
text=span_text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=None,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)[0]
|
||||
audio_out = _render_with_pauses(_gen_span, segments, sr)
|
||||
else:
|
||||
audios = model.generate(
|
||||
text=text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=duration,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)
|
||||
audio_out = audios[0]
|
||||
|
||||
# Apply DSP effect preset
|
||||
_effect_preset = effect_preset or "broadcast"
|
||||
|
||||
|
||||
+70
-1
@@ -23,7 +23,8 @@ Provides:
|
||||
- ``add_punctuation()``: Appends missing end punctuation (Chinese or English).
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
SPLIT_PUNCTUATION = set(".,;:!?。,;:!?")
|
||||
@@ -217,3 +218,71 @@ def add_punctuation(text: str):
|
||||
text += "。" if is_chinese else "."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
# Inline pause marker (issue #276): `[pause]`, `[pause 500ms]`, `[pause 1s]`,
|
||||
# `[pause 1.5s]`. Case-insensitive; whitespace around the number is tolerated.
|
||||
# A bare `[pause]` uses PAUSE_DEFAULT_MS.
|
||||
PAUSE_DEFAULT_MS = 350
|
||||
PAUSE_MAX_MS = 10_000
|
||||
_PAUSE_RE = re.compile(
|
||||
r"\[\s*pause(?:\s+(\d+(?:\.\d+)?)\s*(ms|s)?)?\s*\]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _pause_ms(num, unit):
|
||||
"""Resolve a parsed (number, unit) pair to a clamped millisecond value."""
|
||||
if num is None:
|
||||
return PAUSE_DEFAULT_MS
|
||||
try:
|
||||
value = float(num)
|
||||
except ValueError:
|
||||
return PAUSE_DEFAULT_MS
|
||||
# Bare number or explicit "ms" -> milliseconds; "s" -> seconds.
|
||||
ms = value * 1000.0 if (unit and unit.lower() == "s") else value
|
||||
ms_int = int(round(ms))
|
||||
return max(0, min(ms_int, PAUSE_MAX_MS))
|
||||
|
||||
|
||||
def parse_pause_markers(text):
|
||||
"""Split ``text`` on inline ``[pause ...]`` markers (issue #276).
|
||||
|
||||
Returns a list of ``(span_text, pause_ms_after)`` tuples, in order, where
|
||||
``pause_ms_after`` is the silence (in milliseconds) to insert AFTER that
|
||||
span's synthesized audio. Guarantees:
|
||||
|
||||
- With no markers: ``[(text, 0)]`` -- the original text, no pause.
|
||||
- Concatenating every ``span_text`` (markers removed) reproduces the input
|
||||
minus the markers.
|
||||
- A leading marker yields a first tuple with empty ``span_text`` and the
|
||||
pause (rendered as leading silence, no audio).
|
||||
- Consecutive markers sum their durations (clamped to ``PAUSE_MAX_MS``).
|
||||
|
||||
The caller synthesizes each non-empty ``span_text`` as usual and stitches a
|
||||
silence buffer of the given length between spans -- no model changes needed.
|
||||
"""
|
||||
if not text or "[" not in text:
|
||||
return [(text, 0)]
|
||||
|
||||
segments = []
|
||||
last = 0
|
||||
pending_text = ""
|
||||
for m in _PAUSE_RE.finditer(text):
|
||||
pending_text += text[last:m.start()]
|
||||
last = m.end()
|
||||
pause = _pause_ms(m.group(1), m.group(2))
|
||||
# When two markers are adjacent (no text between), merge the silence
|
||||
# onto the previous segment instead of emitting an empty span.
|
||||
if pending_text == "" and segments:
|
||||
prev_text, prev_pause = segments[-1]
|
||||
segments[-1] = (prev_text, min(prev_pause + pause, PAUSE_MAX_MS))
|
||||
else:
|
||||
segments.append((pending_text, pause))
|
||||
pending_text = ""
|
||||
|
||||
tail = pending_text + text[last:]
|
||||
if tail or not segments:
|
||||
segments.append((tail, 0))
|
||||
|
||||
return segments
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Inline `[pause Nms]` transcript marker (issue #276).
|
||||
|
||||
Covers the pure text parser (`parse_pause_markers`) and the model-free audio
|
||||
stitching (`_render_with_pauses`) — no TTS model is loaded; `gen_span` is a
|
||||
fake that returns known-length tensors so the silence math is deterministic.
|
||||
"""
|
||||
import torch
|
||||
|
||||
from omnivoice.utils.text import (
|
||||
parse_pause_markers,
|
||||
PAUSE_DEFAULT_MS,
|
||||
PAUSE_MAX_MS,
|
||||
)
|
||||
from api.routers.generation import _render_with_pauses
|
||||
|
||||
|
||||
# ── parser ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_no_marker_returns_text_unchanged():
|
||||
assert parse_pause_markers("Hello world") == [("Hello world", 0)]
|
||||
assert parse_pause_markers("") == [("", 0)]
|
||||
|
||||
|
||||
def test_bare_pause_uses_default():
|
||||
assert parse_pause_markers("a[pause]b") == [("a", PAUSE_DEFAULT_MS), ("b", 0)]
|
||||
|
||||
|
||||
def test_explicit_ms_and_seconds():
|
||||
assert parse_pause_markers("a [pause 500ms] b") == [("a ", 500), (" b", 0)]
|
||||
assert parse_pause_markers("a[pause 1s]b") == [("a", 1000), ("b", 0)]
|
||||
assert parse_pause_markers("a[pause 1.5s]b") == [("a", 1500), ("b", 0)]
|
||||
|
||||
|
||||
def test_bare_number_is_milliseconds():
|
||||
assert parse_pause_markers("a[pause 250]b") == [("a", 250), ("b", 0)]
|
||||
|
||||
|
||||
def test_case_insensitive_and_inner_whitespace():
|
||||
assert parse_pause_markers("a[PAUSE 750 ms]b") == [("a", 750), ("b", 0)]
|
||||
|
||||
|
||||
def test_leading_marker_yields_empty_first_span():
|
||||
assert parse_pause_markers("[pause 1s]Hi") == [("", 1000), ("Hi", 0)]
|
||||
|
||||
|
||||
def test_trailing_marker():
|
||||
assert parse_pause_markers("Bye[pause]") == [("Bye", PAUSE_DEFAULT_MS)]
|
||||
|
||||
|
||||
def test_adjacent_markers_sum():
|
||||
assert parse_pause_markers("a[pause][pause 2s]b") == [
|
||||
("a", PAUSE_DEFAULT_MS + 2000),
|
||||
("b", 0),
|
||||
]
|
||||
|
||||
|
||||
def test_duration_clamped():
|
||||
assert parse_pause_markers("a[pause 99s]b") == [("a", PAUSE_MAX_MS), ("b", 0)]
|
||||
|
||||
|
||||
def test_text_round_trips_without_markers():
|
||||
text = "One [pause 200ms] two [pause] three"
|
||||
spans = "".join(t for t, _ in parse_pause_markers(text))
|
||||
assert spans == "One two three"
|
||||
|
||||
|
||||
# ── audio stitching ─────────────────────────────────────────────────────────
|
||||
|
||||
def _fake_gen(sr):
|
||||
# Each span renders to 1 second of mono audio (shape [1, sr]); the value
|
||||
# encodes nothing — we only assert lengths.
|
||||
return lambda text: torch.ones(1, sr)
|
||||
|
||||
|
||||
def test_render_inserts_silence_between_spans():
|
||||
sr = 1000 # 1000 samples/sec keeps the math trivial
|
||||
segs = [("hello", 500), ("world", 0)] # 500ms = 500 samples of silence
|
||||
out = _render_with_pauses(_fake_gen(sr), segs, sr)
|
||||
# 1s audio + 0.5s silence + 1s audio = 2.5s = 2500 samples
|
||||
assert out.shape == (1, 2500)
|
||||
# The middle 500 samples (after the first second) are silence.
|
||||
assert torch.all(out[:, sr:sr + 500] == 0)
|
||||
assert torch.all(out[:, :sr] == 1)
|
||||
|
||||
|
||||
def test_render_leading_silence():
|
||||
sr = 1000
|
||||
segs = [("", 1000), ("hi", 0)] # 1s leading silence + 1s audio
|
||||
out = _render_with_pauses(_fake_gen(sr), segs, sr)
|
||||
assert out.shape == (1, 2000)
|
||||
assert torch.all(out[:, :1000] == 0)
|
||||
assert torch.all(out[:, 1000:] == 1)
|
||||
|
||||
|
||||
def test_render_pause_only_input_is_silence():
|
||||
sr = 1000
|
||||
segs = [("", 750)] # only a pause, no speakable text
|
||||
out = _render_with_pauses(_fake_gen(sr), segs, sr)
|
||||
assert out.numel() == 750
|
||||
assert torch.all(out == 0)
|
||||
|
||||
|
||||
def test_render_no_pause_single_span_passthrough():
|
||||
sr = 1000
|
||||
segs = [("just text", 0)]
|
||||
out = _render_with_pauses(_fake_gen(sr), segs, sr)
|
||||
assert out.shape == (1, sr)
|
||||
Reference in New Issue
Block a user