aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src/voiceforge/ai_worker.py
blob: 99ec9f59aefb131a037bc0b2ef69736f66a4adc9 (plain)
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
"""Private script executed only by the isolated backend Python."""
from __future__ import annotations

import argparse
import hashlib
import json
import os
from pathlib import Path
import signal
import tempfile
import urllib.request
import zipfile


def report(stage: str, completed: float | None = None, total: float | None = None) -> None:
    print("VOICEFORGE_PROGRESS " + json.dumps([stage, completed, total]), flush=True)


def ensure_model(directory: Path) -> Path:
    """Install a release-pinned, hash-checked archive atomically under setup lock."""
    model = directory / "DeepFilterNet3"
    if (model / "config.ini").is_file() and (model / "checkpoints/model_120.ckpt.best").is_file():
        return model
    if directory.exists():
        raise RuntimeError(f"Incomplete model directory: {directory}. Remove it and retry AI setup.")
    directory.parent.mkdir(parents=True, exist_ok=True)
    url = "https://raw.githubusercontent.com/Rikorose/DeepFilterNet/v0.5.6/models/DeepFilterNet3.zip"
    expected = "49c52edc8947ae1f9bf50d81530beaf3a2c3245aeaf34b6f31ff535cd22284d2"
    # Release archive size is fixed alongside its hash, even without Content-Length.
    expected_bytes = 7986207
    downloaded = 0
    report("Downloading DeepFilterNet3 model (bytes)", 0, expected_bytes)
    with tempfile.TemporaryDirectory(prefix=".df3-", dir=directory.parent) as staging:
        archive = Path(staging) / "model.zip"
        digest = hashlib.sha256()
        with urllib.request.urlopen(url, timeout=60) as response, archive.open("wb") as output:
            while chunk := response.read(256 * 1024):
                downloaded += len(chunk)
                if downloaded > expected_bytes:
                    raise RuntimeError("DeepFilterNet3 model download exceeds the expected size")
                digest.update(chunk)
                output.write(chunk)
                report("Downloading DeepFilterNet3 model (bytes)", downloaded, expected_bytes)
        if downloaded != expected_bytes or digest.hexdigest() != expected:
            raise RuntimeError("DeepFilterNet3 model checksum mismatch; refusing to load weights")
        report("Verifying and extracting DeepFilterNet3 model")
        unpacked = Path(staging) / "unpacked"
        with zipfile.ZipFile(archive) as bundle:
            for member in bundle.infolist():
                destination = (unpacked / member.filename).resolve()
                if not destination.is_relative_to(unpacked.resolve()):
                    raise RuntimeError("Unsafe path in model archive")
            bundle.extractall(unpacked)
        os.replace(unpacked, directory)
    return model


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--device", choices=("auto", "cpu", "cuda"), required=True)
    parser.add_argument("--check", action="store_true")
    parser.add_argument("--model-dir", type=Path, required=True)
    parser.add_argument("--source", type=Path)
    parser.add_argument("--target", type=Path)
    parser.add_argument("--strength", type=float, default=1.0)
    args = parser.parse_args()

    # SIGTERM from the parent takes the same cleanup path as Ctrl-C.
    def terminate(signum, frame):
        raise KeyboardInterrupt

    signal.signal(signal.SIGTERM, terminate)

    import numpy as np
    import soundfile as sf
    import torch

    torch.set_num_threads(min(4, os.cpu_count() or 1))
    available = torch.cuda.is_available()
    if args.device == "cuda" and not available:
        raise RuntimeError("CUDA was explicitly requested, but PyTorch cannot use it. "
                           "Check the NVIDIA driver and CUDA_VISIBLE_DEVICES, or select cpu.")
    device = "cuda" if args.device != "cpu" and available else "cpu"
    # df.utils.get_device() consults DEVICE on every call, including model creation.
    os.environ["DEVICE"] = device
    from df.enhance import enhance, init_df
    from df.model import ModelParams
    from libdf import DF

    model_path = ensure_model(args.model_dir)
    report(f"Loading DeepFilterNet3 ({device})")
    model, state, _ = init_df(str(model_path), log_level="ERROR", log_file=None)
    if state.sr() != 48000:
        raise RuntimeError("DeepFilterNet3 model must use 48 kHz")
    if args.check:
        # Exercise both model execution and the compiled libdf/NumPy ABI.
        sample = enhance(model, state, torch.zeros(1, 4800), pad=True)
        if sample.shape != (1, 4800) or not torch.isfinite(sample).all():
            raise RuntimeError("DeepFilterNet3 self-test failed")
        report(f"DeepFilterNet3 ready ({device})", 1, 1)
        return
    if args.source is None or args.target is None:
        parser.error("--source and --target are required unless --check is used")
    if not np.isfinite(args.strength) or not 0 <= args.strength <= 1:
        parser.error("--strength must be in [0, 1]")

    temporary = None
    try:
        with sf.SoundFile(args.source) as source:
            if source.samplerate != 48000 or source.channels != 1 or source.format not in {"WAV", "WAVEX", "RF64"}:
                raise ValueError("AI input must be a mono 48 kHz WAV; convert it before denoising")
            frames = len(source)
            fd, name = tempfile.mkstemp(prefix=".voiceforge-ai-", suffix=".wav", dir=args.target.parent)
            os.close(fd)
            temporary = Path(name)
            # RF64 avoids RIFF's 4 GiB limit on very long recordings.
            output_format = "RF64" if frames * 4 > 0xFFFFFFFF - 4096 else "WAV"
            with sf.SoundFile(temporary, "w", samplerate=48000, channels=1,
                              format=output_format, subtype="FLOAT") as target:
                block, context, overlap = 480000, 96000, 2400
                previous = None
                params = ModelParams()
                report(f"Denoising ({device})", 0, frames)
                for start in range(0, frames, block):
                    end = min(start + block, frames)
                    left, right = max(0, start - context), min(frames, end + overlap + context)
                    source.seek(left)
                    audio = source.read(right - left, dtype="float32")
                    if not np.isfinite(audio).all():
                        raise ValueError("Input contains non-finite audio samples")
                    if args.strength:
                        # Fresh STFT state per window; context warms normalization and
                        # recurrent layers. Pad to a whole hop before delay compensation.
                        state = DF(sr=params.sr, fft_size=params.fft_size,
                                   hop_size=params.hop_size, nb_bands=params.nb_erb,
                                   min_nb_erb_freqs=params.min_nb_freqs)
                        padded = np.pad(audio, (0, (-len(audio)) % params.hop_size))
                        wet = enhance(model, state, torch.from_numpy(padded).unsqueeze(0),
                                      pad=True).squeeze(0).numpy()[:len(audio)]
                        if len(wet) != len(audio) or not np.isfinite(wet).all():
                            raise RuntimeError("DeepFilterNet3 returned invalid audio")
                        audio = audio * (1 - args.strength) + wet * args.strength
                    kept = audio[start - left:min(frames, end + overlap) - left].copy()
                    if previous is not None and args.strength:
                        n = len(previous)
                        fade = np.linspace(0, 1, n, dtype=np.float32)
                        kept[:n] = previous * (1 - fade) + kept[:n] * fade
                    target.write(kept[:end - start])
                    previous = kept[end - start:].copy()
                    report(f"Denoising ({device})", end, frames)
                if target.tell() != frames:
                    raise RuntimeError("Output frame count does not match input")
        os.replace(temporary, args.target)
        temporary = None
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)


if __name__ == "__main__":
    main()