aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src/voiceforge/config.py
blob: 9cbd59d52b19ed3b637b54cfb583e991073e09eb (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
"""Flat, strict TOML configuration; CLI overrides are applied last."""
from dataclasses import asdict, dataclass, fields
import math
from pathlib import Path
import tomllib


@dataclass
class Settings:
    profile: str = "natural"
    denoiser: str = "deepfilter"
    device: str = "auto"
    denoise_strength: float = 0.85
    fft_reduction_db: float = 8.0
    channel: str = "auto"
    highpass: bool = True
    highpass_hz: float = 70.0
    lowpass: bool = False
    lowpass_hz: float = 16000.0
    hum_hz: int = 0
    leveling: bool = True
    level_target_db: float = -23.0
    max_gain_db: float = 18.0
    expansion: bool = False
    expansion_threshold_db: float = -50.0
    expansion_ratio: float = 1.5
    expansion_range_db: float = 12.0
    eq: bool = True
    warmth_db: float = 0.5
    mud_db: float = -1.5
    presence_db: float = 1.0
    compression: bool = True
    compressor_threshold_db: float = -21.0
    compressor_ratio: float = 2.0
    compressor_attack_ms: float = 15.0
    compressor_release_ms: float = 150.0
    deess: bool = True
    deess_intensity: float = 0.15
    deess_amount: float = 0.4
    normalize: bool = True
    target_lufs: float = -19.0
    loudness_range: float = 9.0
    limiter: bool = True
    true_peak_db: float = -1.5
    sample_rate: int = 48000
    mp3: bool = False
    mp3_bitrate: int = 192


PROFILES = {
    "natural": {},
    "narrator": {"compressor_ratio": 1.6, "compressor_attack_ms": 25.0,
                 "presence_db": 0.5, "warmth_db": 1.0, "loudness_range": 11.0},
    "radio": {"compressor_ratio": 3.5, "compressor_threshold_db": -24.0,
              "compressor_attack_ms": 8.0, "compressor_release_ms": 100.0,
              "warmth_db": 2.0, "mud_db": -2.0, "presence_db": 2.0,
              "deess_intensity": 0.25, "loudness_range": 6.0},
    "cleanup-only": {"leveling": False, "eq": False, "compression": False,
                     "deess": False, "normalize": False, "limiter": False},
}

RANGES = {
    "denoise_strength": (0, 1), "fft_reduction_db": (0.01, 30),
    "highpass_hz": (20, 300), "lowpass_hz": (4000, 22000),
    "level_target_db": (-40, -12), "max_gain_db": (0, 30),
    "expansion_threshold_db": (-90, -20), "expansion_ratio": (1, 4),
    "expansion_range_db": (0, 40), "warmth_db": (-12, 12),
    "mud_db": (-12, 12), "presence_db": (-12, 12),
    "compressor_threshold_db": (-50, -5), "compressor_ratio": (1, 10),
    "compressor_attack_ms": (0.1, 200), "compressor_release_ms": (10, 2000),
    "deess_intensity": (0, 1), "deess_amount": (0, 1),
    "target_lufs": (-30, -12), "loudness_range": (1, 20),
    "true_peak_db": (-9, -0.5),
}


def resolve(path: Path | None = None, profile: str | None = None,
            overrides: dict | None = None) -> Settings:
    values = tomllib.loads(path.read_text()) if path else {}
    chosen = (overrides or {}).get("profile", profile or values.get("profile", "natural"))
    if not isinstance(chosen, str) or chosen not in PROFILES:
        raise ValueError(f"Unknown profile {chosen!r}; choose {', '.join(PROFILES)}")
    merged = asdict(Settings()) | PROFILES[chosen] | values | {"profile": chosen}
    merged.update(overrides or {})
    defaults = asdict(Settings())
    unknown = merged.keys() - defaults.keys()
    if unknown:
        raise ValueError(f"Unknown setting(s): {', '.join(sorted(unknown))}")
    for key, value in merged.items():
        expected = type(defaults[key])
        if expected is float:
            if type(value) not in (int, float) or not math.isfinite(value):
                raise ValueError(f"{key} must be a finite number")
        elif type(value) is not expected:
            raise ValueError(f"{key} must be {expected.__name__}")
        if key in RANGES and not RANGES[key][0] <= value <= RANGES[key][1]:
            raise ValueError(f"{key} must be between {RANGES[key][0]} and {RANGES[key][1]}")
    for key, options in {
        "denoiser": ("deepfilter", "fft", "none"), "device": ("auto", "cpu", "cuda"),
        "channel": ("auto", "left", "right", "mix"), "hum_hz": (0, 50, 60),
        "sample_rate": (44100, 48000), "mp3_bitrate": (128, 160, 192, 224, 256, 320),
    }.items():
        if merged[key] not in options:
            raise ValueError(f"{key} must be one of {options}")
    if merged["lowpass"] and merged["lowpass_hz"] >= merged["sample_rate"] / 2:
        raise ValueError("lowpass_hz must be below the output Nyquist frequency")
    if merged["normalize"] and not merged["limiter"]:
        raise ValueError("Normalization includes true-peak limiting; use --no-normalize to bypass it")
    return Settings(**merged)


def toml(settings: Settings) -> str:
    lines = ["# VoiceForge configuration. CLI flags override these values."]
    for field in fields(settings):
        value = getattr(settings, field.name)
        text = str(value).lower() if isinstance(value, bool) else repr(value)
        lines.append(f"{field.name} = {text}")
    return "\n".join(lines) + "\n"