Merge pull request #488 from Beingpax/improved-visualizer

Improved visualizer
This commit is contained in:
Prakash Joshi Pax 2026-01-11 20:47:16 +05:45 committed by GitHub
commit 22b727ff97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 132 additions and 110 deletions

View File

@ -4,110 +4,126 @@ struct AudioVisualizer: View {
let audioMeter: AudioMeter
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 sensitivityMultipliers: [Double]
@State private var barHeights: [CGFloat]
@State private var targetHeights: [CGFloat]
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 phases: [Double]
@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 = 15
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"
}
}
private var animationSpeed: Double {
switch mode {
case .transcribing:
return 0.18
case .enhancing:
return 0.22
}
}
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, animationSpeed: animationSpeed)
}
.frame(height: 28) // Match AudioVisualizer maxHeight for no layout shift
}
}

View File

@ -123,29 +123,46 @@ struct ProcessingIndicator: View {
// MARK: - Progress Animation Component
struct ProgressAnimation: View {
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?
let animationSpeed: Double
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
@ -276,35 +293,21 @@ struct RecorderStatusDisplay: View {
let currentState: RecordingState
let audioMeter: AudioMeter
let menuBarHeight: CGFloat?
init(currentState: RecordingState, audioMeter: AudioMeter, menuBarHeight: CGFloat? = nil) {
self.currentState = currentState
self.audioMeter = audioMeter
self.menuBarHeight = menuBarHeight
}
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)
}
}