Improve audio visualizer and add processing status indicators
This commit is contained in:
parent
c530367a04
commit
db50b490d4
@ -5,109 +5,116 @@ struct AudioVisualizer: View {
|
||||
let color: Color
|
||||
let isActive: Bool
|
||||
|
||||
private let barCount = 12
|
||||
private let minHeight: CGFloat = 5
|
||||
private let maxHeight: CGFloat = 32
|
||||
private let barWidth: CGFloat = 3.5
|
||||
private let barSpacing: CGFloat = 2.3
|
||||
private let hardThreshold: Double = 0.3
|
||||
private let barCount = 15
|
||||
private let barWidth: CGFloat = 3
|
||||
private let barSpacing: CGFloat = 2
|
||||
private let minHeight: CGFloat = 4
|
||||
private let maxHeight: CGFloat = 28
|
||||
|
||||
private let sensitivityMultipliers: [Double]
|
||||
private let phases: [Double]
|
||||
|
||||
@State private var barHeights: [CGFloat]
|
||||
@State private var targetHeights: [CGFloat]
|
||||
@State private var heights: [CGFloat]
|
||||
|
||||
init(audioMeter: AudioMeter, color: Color, isActive: Bool) {
|
||||
self.audioMeter = audioMeter
|
||||
self.color = color
|
||||
self.isActive = isActive
|
||||
|
||||
self.sensitivityMultipliers = (0..<barCount).map { _ in
|
||||
Double.random(in: 0.2...1.9)
|
||||
}
|
||||
|
||||
_barHeights = State(initialValue: Array(repeating: minHeight, count: barCount))
|
||||
_targetHeights = State(initialValue: Array(repeating: minHeight, count: barCount))
|
||||
// Create smooth wave phases
|
||||
self.phases = (0..<barCount).map { Double($0) * 0.4 }
|
||||
_heights = State(initialValue: Array(repeating: minHeight, count: barCount))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: barSpacing) {
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
RoundedRectangle(cornerRadius: 1.7)
|
||||
.fill(color)
|
||||
.frame(width: barWidth, height: barHeights[index])
|
||||
RoundedRectangle(cornerRadius: barWidth / 2)
|
||||
.fill(color.opacity(0.85))
|
||||
.frame(width: barWidth, height: heights[index])
|
||||
}
|
||||
}
|
||||
.onChange(of: audioMeter) { _, newValue in
|
||||
if isActive {
|
||||
updateBars(with: Float(newValue.averagePower))
|
||||
} else {
|
||||
resetBars()
|
||||
}
|
||||
updateWave(level: isActive ? newValue.averagePower : 0)
|
||||
}
|
||||
.onChange(of: isActive) { _, newValue in
|
||||
if !newValue {
|
||||
resetBars()
|
||||
.onChange(of: isActive) { _, active in
|
||||
if !active { resetWave() }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateWave(level: Double) {
|
||||
let time = Date().timeIntervalSince1970
|
||||
let amplitude = max(0, min(1, level))
|
||||
|
||||
// Boost lower levels for better visibility
|
||||
let boosted = pow(amplitude, 0.7)
|
||||
|
||||
withAnimation(.easeOut(duration: 0.08)) {
|
||||
for i in 0..<barCount {
|
||||
let wave = sin(time * 8 + phases[i]) * 0.5 + 0.5
|
||||
let centerDistance = abs(Double(i) - Double(barCount) / 2) / Double(barCount / 2)
|
||||
let centerBoost = 1.0 - (centerDistance * 0.4)
|
||||
|
||||
let height = minHeight + CGFloat(boosted * wave * centerBoost) * (maxHeight - minHeight)
|
||||
heights[i] = max(minHeight, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateBars(with audioLevel: Float) {
|
||||
let rawLevel = max(0, min(1, Double(audioLevel)))
|
||||
let adjustedLevel = rawLevel < hardThreshold ? 0 : (rawLevel - hardThreshold) / (1.0 - hardThreshold)
|
||||
|
||||
let range = maxHeight - minHeight
|
||||
let center = barCount / 2
|
||||
|
||||
for i in 0..<barCount {
|
||||
let distanceFromCenter = abs(i - center)
|
||||
let positionMultiplier = 1.0 - (Double(distanceFromCenter) / Double(center)) * 0.4
|
||||
|
||||
// Use randomized sensitivity
|
||||
let sensitivityAdjustedLevel = adjustedLevel * positionMultiplier * sensitivityMultipliers[i]
|
||||
|
||||
let targetHeight = minHeight + CGFloat(sensitivityAdjustedLevel) * range
|
||||
|
||||
let isDecaying = targetHeight < targetHeights[i]
|
||||
let smoothingFactor: CGFloat = isDecaying ? 0.6 : 0.3 // Adjusted smoothing
|
||||
|
||||
targetHeights[i] = targetHeights[i] * (1 - smoothingFactor) + targetHeight * smoothingFactor
|
||||
|
||||
// Only update if change is significant enough to matter visually
|
||||
if abs(barHeights[i] - targetHeights[i]) > 0.5 {
|
||||
withAnimation(
|
||||
isDecaying
|
||||
? .spring(response: 0.4, dampingFraction: 0.8)
|
||||
: .spring(response: 0.3, dampingFraction: 0.7)
|
||||
) {
|
||||
barHeights[i] = targetHeights[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetBars() {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
barHeights = Array(repeating: minHeight, count: barCount)
|
||||
targetHeights = Array(repeating: minHeight, count: barCount)
|
||||
private func resetWave() {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
heights = Array(repeating: minHeight, count: barCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticVisualizer: View {
|
||||
private let barCount = 12
|
||||
private let barWidth: CGFloat = 3.5
|
||||
private let staticHeight: CGFloat = 5.0
|
||||
private let barSpacing: CGFloat = 2.3
|
||||
// Match AudioVisualizer dimensions
|
||||
private let barCount = 14
|
||||
private let barWidth: CGFloat = 3
|
||||
private let staticHeight: CGFloat = 4
|
||||
private let barSpacing: CGFloat = 2
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: barSpacing) {
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
RoundedRectangle(cornerRadius: 1.7)
|
||||
.fill(color)
|
||||
ForEach(0..<barCount, id: \.self) { _ in
|
||||
RoundedRectangle(cornerRadius: barWidth / 2)
|
||||
.fill(color.opacity(0.5))
|
||||
.frame(width: barWidth, height: staticHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Processing Status Display (Transcribing/Enhancing states)
|
||||
struct ProcessingStatusDisplay: View {
|
||||
enum Mode {
|
||||
case transcribing
|
||||
case enhancing
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let color: Color
|
||||
|
||||
private var label: String {
|
||||
switch mode {
|
||||
case .transcribing:
|
||||
return "Transcribing"
|
||||
case .enhancing:
|
||||
return "Enhancing"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(label)
|
||||
.foregroundColor(color)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
|
||||
ProgressAnimation(color: color)
|
||||
}
|
||||
.frame(height: 28) // Match AudioVisualizer maxHeight for no layout shift
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,29 +123,46 @@ struct ProcessingIndicator: View {
|
||||
|
||||
// MARK: - Progress Animation Component
|
||||
struct ProgressAnimation: View {
|
||||
@State private var currentDot = 0
|
||||
@State private var timer: Timer?
|
||||
let color: Color
|
||||
let animationSpeed: Double
|
||||
|
||||
private let dotCount = 5
|
||||
private let dotSize: CGFloat = 3
|
||||
private let dotSpacing: CGFloat = 2
|
||||
|
||||
@State private var currentDot = 0
|
||||
@State private var timer: Timer?
|
||||
|
||||
init(color: Color = .white, animationSpeed: Double = 0.3) {
|
||||
self.color = color
|
||||
self.animationSpeed = animationSpeed
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<5, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(Color.white.opacity(index <= currentDot ? 0.8 : 0.2))
|
||||
.frame(width: 3.5, height: 3.5)
|
||||
HStack(spacing: dotSpacing) {
|
||||
ForEach(0..<dotCount, id: \.self) { index in
|
||||
RoundedRectangle(cornerRadius: dotSize / 2)
|
||||
.fill(color.opacity(index <= currentDot ? 0.85 : 0.25))
|
||||
.frame(width: dotSize, height: dotSize)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: animationSpeed, repeats: true) { _ in
|
||||
currentDot = (currentDot + 1) % 7
|
||||
if currentDot >= 5 { currentDot = -1 }
|
||||
}
|
||||
startAnimation()
|
||||
}
|
||||
.onDisappear {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimation() {
|
||||
timer?.invalidate()
|
||||
currentDot = 0
|
||||
timer = Timer.scheduledTimer(withTimeInterval: animationSpeed, repeats: true) { _ in
|
||||
currentDot = (currentDot + 1) % (dotCount + 2)
|
||||
if currentDot > dotCount { currentDot = -1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prompt Button Component
|
||||
@ -286,25 +303,11 @@ struct RecorderStatusDisplay: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if currentState == .enhancing {
|
||||
VStack(spacing: 2) {
|
||||
Text("Enhancing")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 11, weight: .medium, design: .default))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
|
||||
ProgressAnimation(animationSpeed: 0.15)
|
||||
}
|
||||
ProcessingStatusDisplay(mode: .enhancing, color: .white)
|
||||
.transition(.opacity)
|
||||
} else if currentState == .transcribing {
|
||||
VStack(spacing: 2) {
|
||||
Text("Transcribing")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 11, weight: .medium, design: .default))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
|
||||
ProgressAnimation(animationSpeed: 0.12)
|
||||
}
|
||||
ProcessingStatusDisplay(mode: .transcribing, color: .white)
|
||||
.transition(.opacity)
|
||||
} else if currentState == .recording {
|
||||
AudioVisualizer(
|
||||
audioMeter: audioMeter,
|
||||
@ -312,10 +315,13 @@ struct RecorderStatusDisplay: View {
|
||||
isActive: currentState == .recording
|
||||
)
|
||||
.scaleEffect(y: menuBarHeight != nil ? min(1.0, (menuBarHeight! - 8) / 25) : 1.0, anchor: .center)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
StaticVisualizer(color: .white)
|
||||
.scaleEffect(y: menuBarHeight != nil ? min(1.0, (menuBarHeight! - 8) / 25) : 1.0, anchor: .center)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: currentState)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user