chore: atualização geral
This commit is contained in:
@@ -359,3 +359,291 @@ def save_num_speakers(num: str) -> str:
|
||||
data["num_speakers"] = val
|
||||
_write_config(data)
|
||||
return val
|
||||
|
||||
|
||||
DEFAULT_VOICE_ANALYSIS_CONFIG: dict = {
|
||||
"energy_threshold": 0.5,
|
||||
"emphasis_weights": {
|
||||
"energy": 0.30,
|
||||
"pitch_variation": 0.25,
|
||||
"rate_variation": 0.20,
|
||||
"pause_before": 0.15,
|
||||
"duration": 0.10,
|
||||
},
|
||||
# Peaks are selected RELATIVELY — the top slice of the distribution —
|
||||
# because the emphasis index is a weighted average whose real range
|
||||
# depends on the material. Measured on a 17-minute interview the index
|
||||
# never passed 0.55, so any absolute cutoff near the spec's 0.85 selects
|
||||
# nothing; on punchier material the same cutoff would flood the edit.
|
||||
# 2% of words is roughly one highlight every 50 words.
|
||||
"peak_percentile": 0.02,
|
||||
# Guard for genuinely flat audio, where even the top of the distribution
|
||||
# carries no emphasis worth cutting on.
|
||||
"emphasis_floor": 0.25,
|
||||
"emotion_enabled": False,
|
||||
"emotion_sensitivity": 0.5,
|
||||
}
|
||||
|
||||
|
||||
def load_voice_analysis_config() -> dict:
|
||||
"""The persisted voice-analysis thresholds/weights, merged over defaults.
|
||||
|
||||
Backs the "Análise de Voz" settings screen: energy threshold (how loud
|
||||
counts as "high energy"), the emphasis-index weights (see
|
||||
``emphasis.EmphasisWeights``), the punch-in emphasis cutoff, and the
|
||||
emotion-detection toggle/sensitivity. Unknown/malformed stored values
|
||||
fall back to the default rather than raising, so a hand-edited or
|
||||
partially-written config.json never breaks the settings screen.
|
||||
"""
|
||||
cfg = {
|
||||
**DEFAULT_VOICE_ANALYSIS_CONFIG,
|
||||
"emphasis_weights": dict(DEFAULT_VOICE_ANALYSIS_CONFIG["emphasis_weights"]),
|
||||
}
|
||||
stored = _load_config().get("voice_analysis")
|
||||
if not isinstance(stored, dict):
|
||||
return cfg
|
||||
for key in ("energy_threshold", "peak_percentile", "emphasis_floor", "emotion_sensitivity"):
|
||||
if key in stored:
|
||||
try:
|
||||
cfg[key] = max(0.0, min(1.0, float(stored[key])))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "emotion_enabled" in stored:
|
||||
cfg["emotion_enabled"] = bool(stored["emotion_enabled"])
|
||||
weights = stored.get("emphasis_weights")
|
||||
if isinstance(weights, dict):
|
||||
for key in cfg["emphasis_weights"]:
|
||||
if key in weights:
|
||||
try:
|
||||
cfg["emphasis_weights"][key] = max(0.0, float(weights[key]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return cfg
|
||||
|
||||
|
||||
def save_voice_analysis_config(
|
||||
energy_threshold: float | None = None,
|
||||
emphasis_weights: dict | None = None,
|
||||
peak_percentile: float | None = None,
|
||||
emphasis_floor: float | None = None,
|
||||
emotion_enabled: bool | None = None,
|
||||
emotion_sensitivity: float | None = None,
|
||||
) -> dict:
|
||||
"""Persist voice-analysis thresholds/weights. Only given fields change.
|
||||
|
||||
Returns the full merged config (same shape as
|
||||
:func:`load_voice_analysis_config`) so callers can render it back
|
||||
immediately without a second round-trip.
|
||||
"""
|
||||
cfg = load_voice_analysis_config()
|
||||
if energy_threshold is not None:
|
||||
cfg["energy_threshold"] = max(0.0, min(1.0, float(energy_threshold)))
|
||||
if peak_percentile is not None:
|
||||
cfg["peak_percentile"] = max(0.0, min(1.0, float(peak_percentile)))
|
||||
if emphasis_floor is not None:
|
||||
cfg["emphasis_floor"] = max(0.0, min(1.0, float(emphasis_floor)))
|
||||
if emotion_enabled is not None:
|
||||
cfg["emotion_enabled"] = bool(emotion_enabled)
|
||||
if emotion_sensitivity is not None:
|
||||
cfg["emotion_sensitivity"] = max(0.0, min(1.0, float(emotion_sensitivity)))
|
||||
if emphasis_weights is not None:
|
||||
for key, value in emphasis_weights.items():
|
||||
if key in cfg["emphasis_weights"] and value is not None:
|
||||
cfg["emphasis_weights"][key] = max(0.0, float(value))
|
||||
data = _load_config()
|
||||
data["voice_analysis"] = cfg
|
||||
_write_config(data)
|
||||
return cfg
|
||||
|
||||
|
||||
# Mirrors the "Legendas Dinâmicas" tab's own defaults (MacApp/Sources/
|
||||
# CaptionsView.swift), so a fresh install shows the same look in the UI and
|
||||
# in what generate_dynamic_subtitles renders when no override is passed.
|
||||
DEFAULT_DYNAMIC_SUBTITLE_CONFIG: dict = {
|
||||
"band_height": 0.22,
|
||||
"block_center_y": -167.0,
|
||||
"line_gap": 8.0,
|
||||
"font": "Helvetica Neue",
|
||||
"font_size": 104,
|
||||
"emphasis_font": "Playfair Display",
|
||||
"emphasis_face": "Medium Italic",
|
||||
"emphasis_size": 265,
|
||||
"active_color": "1 1 1 1",
|
||||
"emphasis_color": "1 1 1 1",
|
||||
"text_scale": 2.0,
|
||||
}
|
||||
|
||||
|
||||
def load_dynamic_subtitle_config() -> dict:
|
||||
"""The persisted dynamic-subtitle style, merged over defaults.
|
||||
|
||||
Backs the "Legendas Dinâmicas" settings screen AND is the fallback
|
||||
``generate_dynamic_subtitles`` reads for any field the caller doesn't
|
||||
explicitly override — so the style configured in the UI is what actually
|
||||
renders, without the app having to thread every field through each call.
|
||||
Unknown/malformed stored values fall back to the default, same as
|
||||
:func:`load_voice_analysis_config`.
|
||||
"""
|
||||
cfg = dict(DEFAULT_DYNAMIC_SUBTITLE_CONFIG)
|
||||
stored = _load_config().get("dynamic_subtitles")
|
||||
if not isinstance(stored, dict):
|
||||
return cfg
|
||||
for key in ("band_height", "block_center_y", "line_gap", "text_scale"):
|
||||
if key in stored:
|
||||
try:
|
||||
cfg[key] = float(stored[key])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for key in ("font_size", "emphasis_size"):
|
||||
if key in stored:
|
||||
try:
|
||||
cfg[key] = int(stored[key])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for key in ("font", "emphasis_font", "emphasis_face", "active_color", "emphasis_color"):
|
||||
if key in stored and isinstance(stored[key], str) and stored[key]:
|
||||
cfg[key] = stored[key]
|
||||
return cfg
|
||||
|
||||
|
||||
def save_dynamic_subtitle_config(**fields) -> dict:
|
||||
"""Persist dynamic-subtitle style fields. Only given fields change.
|
||||
|
||||
Accepts the same keys as :data:`DEFAULT_DYNAMIC_SUBTITLE_CONFIG`; unknown
|
||||
keys are ignored so a newer app talking to an older config shape degrades
|
||||
quietly. Returns the full merged config, mirroring
|
||||
:func:`save_voice_analysis_config`.
|
||||
"""
|
||||
cfg = load_dynamic_subtitle_config()
|
||||
for key, value in fields.items():
|
||||
if key not in DEFAULT_DYNAMIC_SUBTITLE_CONFIG or value is None:
|
||||
continue
|
||||
if isinstance(DEFAULT_DYNAMIC_SUBTITLE_CONFIG[key], float):
|
||||
try:
|
||||
cfg[key] = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
elif isinstance(DEFAULT_DYNAMIC_SUBTITLE_CONFIG[key], int):
|
||||
try:
|
||||
cfg[key] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
else:
|
||||
cfg[key] = str(value)
|
||||
data = _load_config()
|
||||
data["dynamic_subtitles"] = cfg
|
||||
_write_config(data)
|
||||
return cfg
|
||||
|
||||
|
||||
# Mirrors the silence thresholds the detection/removal handlers use when no
|
||||
# argument is passed (server_tools/qc.py). Persisted so the app's slider and
|
||||
# any later run agree without threading three fields through every call.
|
||||
DEFAULT_SILENCE_CONFIG: dict = {
|
||||
# dBFS below which audio counts as silence.
|
||||
"noise_db": -30.0,
|
||||
# Seconds a quiet stretch must last before it's a cut candidate.
|
||||
"min_silence": 0.5,
|
||||
# Seconds left inside each cut so speech never gets clipped at the edges.
|
||||
"padding": 0.05,
|
||||
}
|
||||
|
||||
|
||||
def load_silence_config() -> dict:
|
||||
"""The persisted silence-detection thresholds, merged over defaults.
|
||||
|
||||
Read by ``detect_media_silence``/``remove_media_silence`` as their
|
||||
fallback, so the tolerance chosen in the app is what actually runs.
|
||||
Malformed stored values fall back to the default rather than raising,
|
||||
matching :func:`load_voice_analysis_config`.
|
||||
"""
|
||||
cfg = dict(DEFAULT_SILENCE_CONFIG)
|
||||
stored = _load_config().get("silence")
|
||||
if not isinstance(stored, dict):
|
||||
return cfg
|
||||
for key in cfg:
|
||||
if key in stored:
|
||||
try:
|
||||
cfg[key] = float(stored[key])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return cfg
|
||||
|
||||
|
||||
def save_silence_config(
|
||||
noise_db: float | None = None,
|
||||
min_silence: float | None = None,
|
||||
padding: float | None = None,
|
||||
) -> dict:
|
||||
"""Persist silence thresholds. Only the given fields change.
|
||||
|
||||
Values are clamped to the same ranges the handlers validate against, so
|
||||
a bad write here can't produce a config the tools would later reject.
|
||||
"""
|
||||
cfg = load_silence_config()
|
||||
if noise_db is not None:
|
||||
try:
|
||||
cfg["noise_db"] = max(-120.0, min(0.0, float(noise_db)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if min_silence is not None:
|
||||
try:
|
||||
cfg["min_silence"] = max(0.01, min(3600.0, float(min_silence)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if padding is not None:
|
||||
try:
|
||||
cfg["padding"] = max(0.0, min(5.0, float(padding)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
data = _load_config()
|
||||
data["silence"] = cfg
|
||||
_write_config(data)
|
||||
return cfg
|
||||
|
||||
|
||||
# Last project worked on, so the app reopens where the user left off instead of
|
||||
# making them pick the folder again every launch. Only paths that still exist
|
||||
# are handed back — a project on an unmounted volume degrades to "none selected"
|
||||
# rather than to a dead path the tools would later fail on.
|
||||
DEFAULT_PROJECT_CONFIG: dict = {
|
||||
# Folder every generated file (transcript .json, XML, SRT) is written to.
|
||||
"folder": "",
|
||||
# The .fcpxml/.fcpxmld that was loaded from it.
|
||||
"file": "",
|
||||
}
|
||||
|
||||
|
||||
def load_project_config() -> dict:
|
||||
"""The persisted last project (folder + file), merged over defaults.
|
||||
|
||||
Paths that no longer exist on disk come back empty, matching what the app
|
||||
shows for "nothing selected". Malformed stored values fall back to the
|
||||
default rather than raising, same as :func:`load_voice_analysis_config`.
|
||||
"""
|
||||
cfg = dict(DEFAULT_PROJECT_CONFIG)
|
||||
stored = _load_config().get("project")
|
||||
if not isinstance(stored, dict):
|
||||
return cfg
|
||||
for key in cfg:
|
||||
value = stored.get(key)
|
||||
if isinstance(value, str) and value and Path(value).exists():
|
||||
cfg[key] = value
|
||||
return cfg
|
||||
|
||||
|
||||
def save_project_config(folder: str | None = None, file: str | None = None) -> dict:
|
||||
"""Persist the last project folder/file. Only the given fields change.
|
||||
|
||||
Passing an empty string clears a field (the app does this when the user
|
||||
deselects), while ``None`` leaves it untouched.
|
||||
"""
|
||||
cfg = load_project_config()
|
||||
for key, value in (("folder", folder), ("file", file)):
|
||||
if value is None:
|
||||
continue
|
||||
cfg[key] = str(Path(value).expanduser()) if str(value).strip() else ""
|
||||
data = _load_config()
|
||||
data["project"] = cfg
|
||||
_write_config(data)
|
||||
return cfg
|
||||
|
||||
Reference in New Issue
Block a user