File size: 9,795 Bytes
4d1131c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""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)