Files
gart/code/MacApp/Sources/VoiceAnalysisView.swift
T

259 lines
9.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// Guia "Análise de Voz" — parâmetros do motor de análise acústica da fala.
///
/// Controla os limiares que decidem quais palavras são candidatas a
/// punch-in/destaque: energia (intensidade da fala), o índice de ênfase e
/// seus pesos, e a camada opcional de emoção. Os valores ficam em
/// `~/.fcp-mcp-server/config.json` (via `model_manager.save_voice_analysis_config`),
/// os mesmos lidos pelas ferramentas de análise — não em UserDefaults, para
/// que a análise rode com exatamente o que a tela mostra.
struct VoiceAnalysisView: View {
@State private var config = VoiceAnalysisConfig.defaults
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
Form {
if isLoading {
Section {
ProgressView().controlSize(.small)
.frame(maxWidth: .infinity, alignment: .center)
}
} else {
energySection
emphasisSection
weightsSection
emotionSection
resetSection
}
if let errorMessage {
Section {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}
}
.formStyle(.grouped)
.task { await load() }
}
// MARK: - Energia
private var energySection: some View {
Section {
sliderRow(
title: "Limiar de energia",
value: $config.energyThreshold,
help: "Acima deste valor a fala conta como \"alta energia\"."
)
} header: {
Text("Energia da Fala")
} footer: {
Text("A energia de cada palavra é normalizada (0–1) pelo trecho mais alto do áudio. Valores mais baixos marcam mais palavras como intensas.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: - Ênfase
private var emphasisSection: some View {
Section {
sliderRow(
title: "Limiar de ênfase",
value: $config.emphasisThreshold,
help: "Acima deste valor a palavra vira candidata a punch-in/destaque."
)
} header: {
Text("Índice de Ênfase")
} footer: {
Text("O índice é a média ponderada de energia, variação de tom, variação de ritmo, pausa anterior e duração — com os pesos abaixo. Por ser média, picos reais ficam entre 0,55 e 0,80: acima de 0,85 quase nada é selecionado.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var weightsSection: some View {
Section {
sliderRow(title: "Energia", value: $config.weightEnergy, range: 0...1)
sliderRow(title: "Variação de tom", value: $config.weightPitch, range: 0...1)
sliderRow(title: "Variação de ritmo", value: $config.weightRate, range: 0...1)
sliderRow(title: "Pausa anterior", value: $config.weightPause, range: 0...1)
sliderRow(title: "Duração da palavra", value: $config.weightDuration, range: 0...1)
} header: {
Text("Pesos do Índice de Ênfase")
} footer: {
Text("Não precisam somar 1 — são normalizados internamente. O que importa é a proporção entre eles.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: - Emoção
private var emotionSection: some View {
Section {
Toggle("Detectar emoção durante a fala", isOn: $config.emotionEnabled)
.onChange(of: config.emotionEnabled) { _, _ in save() }
if config.emotionEnabled {
sliderRow(
title: "Sensibilidade",
value: $config.emotionSensitivity,
help: "Confiança mínima para aceitar uma emoção detectada."
)
}
} header: {
Text("Emoção")
} footer: {
Text("A emoção nunca decide um corte sozinha — entra combinada com energia, tom e ênfase. Exige o componente opcional de emoção instalado; sem ele, a análise segue normalmente sem essa camada.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var resetSection: some View {
Section {
Button("Restaurar padrões") {
config = .defaults
save()
}
}
}
// MARK: - Componentes
/// Slider com rótulo à esquerda e valor numérico à direita, salvando no
/// backend só quando o arraste termina (evita uma escrita por quadro).
private func sliderRow(
title: String,
value: Binding<Double>,
range: ClosedRange<Double> = 0...1,
help: String? = nil
) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(title)
Spacer()
Text(String(format: "%.2f", value.wrappedValue))
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: value, in: range) { editing in
if !editing { save() }
}
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Backend
@MainActor
private func load() async {
await withCheckedContinuation { continuation in
PythonBridge.call(command: "voice_analysis") { result, error in
DispatchQueue.main.async {
if let result {
config = VoiceAnalysisConfig(from: result)
} else if let error {
errorMessage = error
}
isLoading = false
continuation.resume()
}
}
}
}
private func save() {
PythonBridge.call(command: "set_voice_analysis", arguments: config.arguments()) { _, error in
DispatchQueue.main.async { errorMessage = error }
}
}
}
/// Os parâmetros de análise de voz, no formato que a tela edita e o bridge
/// (`admin/models_api.py` → `set_voice_analysis`) persiste.
struct VoiceAnalysisConfig {
var energyThreshold: Double
var emphasisThreshold: Double
var weightEnergy: Double
var weightPitch: Double
var weightRate: Double
var weightPause: Double
var weightDuration: Double
var emotionEnabled: Bool
var emotionSensitivity: Double
static let defaults = VoiceAnalysisConfig(
energyThreshold: 0.5,
emphasisThreshold: 0.60,
weightEnergy: 0.30,
weightPitch: 0.25,
weightRate: 0.20,
weightPause: 0.15,
weightDuration: 0.10,
emotionEnabled: false,
emotionSensitivity: 0.5
)
init(
energyThreshold: Double,
emphasisThreshold: Double,
weightEnergy: Double,
weightPitch: Double,
weightRate: Double,
weightPause: Double,
weightDuration: Double,
emotionEnabled: Bool,
emotionSensitivity: Double
) {
self.energyThreshold = energyThreshold
self.emphasisThreshold = emphasisThreshold
self.weightEnergy = weightEnergy
self.weightPitch = weightPitch
self.weightRate = weightRate
self.weightPause = weightPause
self.weightDuration = weightDuration
self.emotionEnabled = emotionEnabled
self.emotionSensitivity = emotionSensitivity
}
/// Lê a resposta do bridge, caindo no padrão para qualquer campo ausente.
init(from json: [String: Any]) {
let defaults = VoiceAnalysisConfig.defaults
let weights = json["emphasis_weights"] as? [String: Any] ?? [:]
self.init(
energyThreshold: json["energy_threshold"] as? Double ?? defaults.energyThreshold,
emphasisThreshold: json["emphasis_threshold"] as? Double ?? defaults.emphasisThreshold,
weightEnergy: weights["energy"] as? Double ?? defaults.weightEnergy,
weightPitch: weights["pitch_variation"] as? Double ?? defaults.weightPitch,
weightRate: weights["rate_variation"] as? Double ?? defaults.weightRate,
weightPause: weights["pause_before"] as? Double ?? defaults.weightPause,
weightDuration: weights["duration"] as? Double ?? defaults.weightDuration,
emotionEnabled: json["emotion_enabled"] as? Bool ?? defaults.emotionEnabled,
emotionSensitivity: json["emotion_sensitivity"] as? Double ?? defaults.emotionSensitivity
)
}
func arguments() -> [String: Any] {
[
"energy_threshold": energyThreshold,
"emphasis_threshold": emphasisThreshold,
"emphasis_weights": [
"energy": weightEnergy,
"pitch_variation": weightPitch,
"rate_variation": weightRate,
"pause_before": weightPause,
"duration": weightDuration,
],
"emotion_enabled": emotionEnabled,
"emotion_sensitivity": emotionSensitivity,
]
}
}