OGG-Comfy / ogg /editor.py
supermind's picture
Upload 58 files
4d1131c verified
Raw History Blame Contribute Delete
9.8 kB
"""The OGG Editor's model of a take: an analysis of the decoded audio (lines, words, loudness), a plan that turns the
editor's settings and manual edits into an edit list, and the rendering of that list.
Times are source seconds unless a name says otherwise. The plan works like the silence tools of audio editors
(Audacity's Truncate Silence, Descript's Shorten word gaps): a pause longer than a limit is shortened to a target by
removing its middle, so the decay of the last word and the breath before the next stay. Gaps between lines have their
own range, so lines that run into each other get room and long gaps between speakers are tightened.
"""
import bisect
import json
import re
import torch
from .qwen3_asr.align import align, words
ENVELOPE_STEP = 0.01
MIN_PAUSE = 0.1
FADE = 0.004
# non-spoken spans of a line: [cues], <|tokens|>, and the markers (not the words) of <scoped>...</scoped> tags
NOT_SPOKEN = re.compile(r"\[[^\[\]]*\]|<\|[^|>]*\|>|</?[A-Za-z][\w-]*\b[^>]*>")
DEFAULTS = {
"threshold_db": -50.0,
"trim_start": True, "lead": 0.05,
"trim_end": True, "tail": 0.15,
"pause_longer": 0.75, "pause_to": 0.75,
"gap_min": 0.3, "gap_max": 1.0,
"keep": [], "cuts": [], "inserts": [],
"speakers": {},
"normalize": False,
}
def parse_edits(text):
"""The editor's settings and edits (its hidden widget's JSON) over the defaults."""
edits = dict(DEFAULTS)
if text:
edits.update(json.loads(text))
return edits
def tokens(text):
"""Display tokens of a line: {"text", "spoken"}; a director's note ("Direction: ...\\nLine: ...") is not spoken."""
out = []
note = re.match(r"\s*Direction:(.*?)\n\s*Line:", text, re.S)
if note:
out.append({"text": note.group(0).strip(), "spoken": False})
text = text[note.end():]
position = 0
for match in NOT_SPOKEN.finditer(text):
out += [{"text": word, "spoken": True} for word in text[position:match.start()].split()]
out.append({"text": match.group(0), "spoken": False})
position = match.end()
out += [{"text": word, "spoken": True} for word in text[position:].split()]
return out
def analyze(waveform, sample_rate, lines, aligner=None):
"""The take's analysis for the editor: duration, a loudness envelope (dB per ENVELOPE_STEP), and its lines in
seconds with display tokens, each spoken one timed when an aligner is given. `lines` are Generate's, in frames."""
frame = 2048 / sample_rate
step = int(ENVELOPE_STEP * sample_rate)
power = waveform[:waveform.shape[-1] // step * step].reshape(-1, step).pow(2).mean(-1)
envelope = [round(value, 1) for value in (10 * torch.log10(power + 1e-12)).tolist()]
out = []
for line in lines:
start, end = line["start"] * frame, line["end"] * frame
line_tokens = tokens(line["text"])
if aligner is not None and end > start:
spoken = [token for token in line_tokens if token["spoken"]]
timed = iter(align(aligner, waveform[None, int(start * sample_rate):int(end * sample_rate)], sample_rate,
" ".join(token["text"] for token in spoken)))
for token in spoken:
units = [next(timed) for _ in words(token["text"])]
if units:
token["start"], token["end"] = round(start + units[0][1], 3), round(start + units[-1][2], 3)
out.append({"speaker": line["speaker"], "chunk": line["chunk"], "start": round(start, 3), "end": round(end, 3),
"text": line["text"], "tokens": line_tokens})
return {"sample_rate": sample_rate, "duration": round(waveform.shape[-1] / sample_rate, 3), "envelope_step": ENVELOPE_STEP,
"envelope": envelope, "lines": out, "speakers": sorted({line["speaker"] for line in out}),
"aligned": aligner is not None}
def pauses(analysis, threshold_db):
"""Quiet runs of at least MIN_PAUSE: [start, end] seconds."""
step, runs, start = analysis["envelope_step"], [], None
for index, value in enumerate(analysis["envelope"] + [0.0]):
quiet = value < threshold_db
if quiet and start is None:
start = index
elif not quiet and start is not None:
if (index - start) * step >= MIN_PAUSE:
runs.append([round(start * step, 3), round(min(index * step, analysis["duration"]), 3)])
start = None
return runs
def plan(analysis, edits):
"""The edit list for `edits`: {"pieces": [{"src": [a, b], "line": i} or {"gap": seconds, "line": i}],
"pauses": [{"id", "start", "end", "kind", "action", "to"}], "duration": seconds after editing}."""
duration, lines = analysis["duration"], analysis["lines"]
boundaries = [line["end"] for line in lines[:-1]]
removed, inserted, found = [], [], []
keep = set(edits["keep"])
covered = set()
sound = [run for run in pauses(analysis, edits["threshold_db"])]
for start, end in sound:
length, pause_id = end - start, f"{start:.2f}"
boundary = next((b for b in boundaries if start - 0.05 <= b <= end + 0.05), None)
kind = "lead" if start <= 0 else "tail" if end >= duration else "between" if boundary is not None else "inside"
action, to = "kept", length
if pause_id in keep:
action = "kept by you"
elif kind == "lead" and edits["trim_start"]:
to = min(length, edits["lead"])
elif kind == "tail" and edits["trim_end"]:
to = min(length, edits["tail"])
elif kind == "between":
covered.add(boundary)
to = min(max(length, edits["gap_min"]), edits["gap_max"])
elif kind == "inside" and length > edits["pause_longer"]:
to = edits["pause_to"]
if to < length - 1e-3:
action = "shortened"
if kind == "lead":
removed.append([start, end - to])
elif kind == "tail":
removed.append([start + to, end])
else:
removed.append([start + to / 2, end - to / 2])
elif to > length + 1e-3:
action = "lengthened"
inserted.append([(start + end) / 2, to - length])
found.append({"id": pause_id, "start": start, "end": end, "kind": kind, "action": action, "to": round(to, 3)})
# lines that run straight into each other get the minimum gap
for boundary in boundaries:
if boundary not in covered and edits["gap_min"] > 0:
inserted.append([boundary, edits["gap_min"]])
removed += [list(cut) for cut in edits["cuts"]]
inserted += [list(insert) for insert in edits["inserts"]]
pieces = cut_pieces(duration, removed, inserted, [line["start"] for line in lines])
total = sum(piece["src"][1] - piece["src"][0] if "src" in piece else piece["gap"] for piece in pieces)
return {"pieces": pieces, "pauses": found, "duration": round(total, 3)}
def cut_pieces(duration, removed, inserted, line_starts):
"""Source pieces between the removed ranges, split at line starts, with each inserted gap at its time (or at the next
kept audio, when its time was removed)."""
kept, position = [], 0.0
for a, b in sorted([max(0.0, a), min(duration, b)] for a, b in removed if b > a):
if a > position:
kept.append([position, a])
position = max(position, b)
if position < duration:
kept.append([position, duration])
def line_at(t):
return max(0, bisect.bisect_right(line_starts, t + 1e-6) - 1)
pending, pieces = sorted(inserted), []
for a, b in kept:
points = sorted({t for t in line_starts if a < t < b} | {t for t, _ in pending if a < t < b})
for x, y in zip([a, *points], [*points, b]):
while pending and pending[0][0] <= x + 1e-6:
pieces.append({"gap": round(pending.pop(0)[1], 3), "line": line_at(x)})
pieces.append({"src": [round(x, 4), round(y, 4)], "line": line_at(x)})
pieces += [{"gap": round(seconds, 3), "line": line_at(t)} for t, seconds in pending]
return pieces
def render(waveform, analysis, pieces, edits):
"""The edited take [samples]: source pieces with FADE ramps where they do not continue the previous one, silence for
gaps, each speaker's gain and mute, then an optional peak normalization to -1 dBFS."""
rate = analysis["sample_rate"]
lines = analysis["lines"]
fade = max(1, int(FADE * rate))
def joins(first, second):
return first is not None and second is not None and "src" in first and "src" in second and first["src"][1] == second["src"][0]
out = []
for index, piece in enumerate(pieces):
if "gap" in piece:
out.append(torch.zeros(int(round(piece["gap"] * rate))))
continue
a, b = int(round(piece["src"][0] * rate)), int(round(piece["src"][1] * rate))
audio = waveform[a:b].clone()
speaker = lines[piece["line"]]["speaker"] if piece["line"] is not None and lines else 0
setting = edits["speakers"].get(str(speaker), {})
if setting.get("mute"):
audio.zero_()
elif setting.get("gain_db"):
audio *= 10 ** (setting["gain_db"] / 20)
if len(audio) > 2 * fade:
if not joins(pieces[index - 1] if index else None, piece):
audio[:fade] *= torch.linspace(0, 1, fade)
if not joins(piece, pieces[index + 1] if index + 1 < len(pieces) else None):
audio[-fade:] *= torch.linspace(1, 0, fade)
out.append(audio)
result = torch.cat(out) if out else torch.zeros(1)
if edits["normalize"]:
peak = result.abs().max().item()
if peak > 0:
result = result * (10 ** (-1 / 20) / peak)
return result.clamp(-1, 1)