chore: atualização geral

This commit is contained in:
João Henrique
2026-08-19 16:35:29 -04:00
parent 8fca456ceb
commit e7748c2c58
66 changed files with 13037 additions and 4237 deletions
+288
View File
@@ -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