Merge pull request #488 from Beingpax/improved-visualizer
Improved visualizer
This commit is contained in:
commit
22b727ff97
@ -4,110 +4,126 @@ struct AudioVisualizer: View {
|
|||||||
let audioMeter: AudioMeter
|
let audioMeter: AudioMeter
|
||||||
let color: Color
|
let color: Color
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
|
|
||||||
private let barCount = 12
|
private let barCount = 15
|
||||||
private let minHeight: CGFloat = 5
|
private let barWidth: CGFloat = 3
|
||||||
private let maxHeight: CGFloat = 32
|
private let barSpacing: CGFloat = 2
|
||||||
private let barWidth: CGFloat = 3.5
|
private let minHeight: CGFloat = 4
|
||||||
private let barSpacing: CGFloat = 2.3
|
private let maxHeight: CGFloat = 28
|
||||||
private let hardThreshold: Double = 0.3
|
|
||||||
|
private let phases: [Double]
|
||||||
private let sensitivityMultipliers: [Double]
|
|
||||||
|
@State private var heights: [CGFloat]
|
||||||
@State private var barHeights: [CGFloat]
|
|
||||||
@State private var targetHeights: [CGFloat]
|
|
||||||
|
|
||||||
init(audioMeter: AudioMeter, color: Color, isActive: Bool) {
|
init(audioMeter: AudioMeter, color: Color, isActive: Bool) {
|
||||||
self.audioMeter = audioMeter
|
self.audioMeter = audioMeter
|
||||||
self.color = color
|
self.color = color
|
||||||
self.isActive = isActive
|
self.isActive = isActive
|
||||||
|
|
||||||
self.sensitivityMultipliers = (0..<barCount).map { _ in
|
// Create smooth wave phases
|
||||||
Double.random(in: 0.2...1.9)
|
self.phases = (0..<barCount).map { Double($0) * 0.4 }
|
||||||
}
|
_heights = State(initialValue: Array(repeating: minHeight, count: barCount))
|
||||||
|
|
||||||
_barHeights = State(initialValue: Array(repeating: minHeight, count: barCount))
|
|
||||||
_targetHeights = State(initialValue: Array(repeating: minHeight, count: barCount))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: barSpacing) {
|
HStack(spacing: barSpacing) {
|
||||||
ForEach(0..<barCount, id: \.self) { index in
|
ForEach(0..<barCount, id: \.self) { index in
|
||||||
RoundedRectangle(cornerRadius: 1.7)
|
RoundedRectangle(cornerRadius: barWidth / 2)
|
||||||
.fill(color)
|
.fill(color.opacity(0.85))
|
||||||
.frame(width: barWidth, height: barHeights[index])
|
.frame(width: barWidth, height: heights[index])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: audioMeter) { _, newValue in
|
.onChange(of: audioMeter) { _, newValue in
|
||||||
if isActive {
|
updateWave(level: isActive ? newValue.averagePower : 0)
|
||||||
updateBars(with: Float(newValue.averagePower))
|
|
||||||
} else {
|
|
||||||
resetBars()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: isActive) { _, newValue in
|
.onChange(of: isActive) { _, active in
|
||||||
if !newValue {
|
if !active { resetWave() }
|
||||||
resetBars()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
private func resetWave() {
|
||||||
let rawLevel = max(0, min(1, Double(audioLevel)))
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
let adjustedLevel = rawLevel < hardThreshold ? 0 : (rawLevel - hardThreshold) / (1.0 - hardThreshold)
|
heights = Array(repeating: minHeight, count: barCount)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StaticVisualizer: View {
|
struct StaticVisualizer: View {
|
||||||
private let barCount = 12
|
// Match AudioVisualizer dimensions
|
||||||
private let barWidth: CGFloat = 3.5
|
private let barCount = 15
|
||||||
private let staticHeight: CGFloat = 5.0
|
private let barWidth: CGFloat = 3
|
||||||
private let barSpacing: CGFloat = 2.3
|
private let staticHeight: CGFloat = 4
|
||||||
|
private let barSpacing: CGFloat = 2
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: barSpacing) {
|
HStack(spacing: barSpacing) {
|
||||||
ForEach(0..<barCount, id: \.self) { index in
|
ForEach(0..<barCount, id: \.self) { _ in
|
||||||
RoundedRectangle(cornerRadius: 1.7)
|
RoundedRectangle(cornerRadius: barWidth / 2)
|
||||||
.fill(color)
|
.fill(color.opacity(0.5))
|
||||||
.frame(width: barWidth, height: staticHeight)
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -123,29 +123,46 @@ struct ProcessingIndicator: View {
|
|||||||
|
|
||||||
// MARK: - Progress Animation Component
|
// MARK: - Progress Animation Component
|
||||||
struct ProgressAnimation: View {
|
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 currentDot = 0
|
||||||
@State private var timer: Timer?
|
@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 {
|
var body: some View {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: dotSpacing) {
|
||||||
ForEach(0..<5, id: \.self) { index in
|
ForEach(0..<dotCount, id: \.self) { index in
|
||||||
Circle()
|
RoundedRectangle(cornerRadius: dotSize / 2)
|
||||||
.fill(Color.white.opacity(index <= currentDot ? 0.8 : 0.2))
|
.fill(color.opacity(index <= currentDot ? 0.85 : 0.25))
|
||||||
.frame(width: 3.5, height: 3.5)
|
.frame(width: dotSize, height: dotSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: animationSpeed, repeats: true) { _ in
|
startAnimation()
|
||||||
currentDot = (currentDot + 1) % 7
|
|
||||||
if currentDot >= 5 { currentDot = -1 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
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
|
// MARK: - Prompt Button Component
|
||||||
@ -276,35 +293,21 @@ struct RecorderStatusDisplay: View {
|
|||||||
let currentState: RecordingState
|
let currentState: RecordingState
|
||||||
let audioMeter: AudioMeter
|
let audioMeter: AudioMeter
|
||||||
let menuBarHeight: CGFloat?
|
let menuBarHeight: CGFloat?
|
||||||
|
|
||||||
init(currentState: RecordingState, audioMeter: AudioMeter, menuBarHeight: CGFloat? = nil) {
|
init(currentState: RecordingState, audioMeter: AudioMeter, menuBarHeight: CGFloat? = nil) {
|
||||||
self.currentState = currentState
|
self.currentState = currentState
|
||||||
self.audioMeter = audioMeter
|
self.audioMeter = audioMeter
|
||||||
self.menuBarHeight = menuBarHeight
|
self.menuBarHeight = menuBarHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if currentState == .enhancing {
|
if currentState == .enhancing {
|
||||||
VStack(spacing: 2) {
|
ProcessingStatusDisplay(mode: .enhancing, color: .white)
|
||||||
Text("Enhancing")
|
.transition(.opacity)
|
||||||
.foregroundColor(.white)
|
|
||||||
.font(.system(size: 11, weight: .medium, design: .default))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
|
|
||||||
ProgressAnimation(animationSpeed: 0.15)
|
|
||||||
}
|
|
||||||
} else if currentState == .transcribing {
|
} else if currentState == .transcribing {
|
||||||
VStack(spacing: 2) {
|
ProcessingStatusDisplay(mode: .transcribing, color: .white)
|
||||||
Text("Transcribing")
|
.transition(.opacity)
|
||||||
.foregroundColor(.white)
|
|
||||||
.font(.system(size: 11, weight: .medium, design: .default))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
|
|
||||||
ProgressAnimation(animationSpeed: 0.12)
|
|
||||||
}
|
|
||||||
} else if currentState == .recording {
|
} else if currentState == .recording {
|
||||||
AudioVisualizer(
|
AudioVisualizer(
|
||||||
audioMeter: audioMeter,
|
audioMeter: audioMeter,
|
||||||
@ -312,10 +315,13 @@ struct RecorderStatusDisplay: View {
|
|||||||
isActive: currentState == .recording
|
isActive: currentState == .recording
|
||||||
)
|
)
|
||||||
.scaleEffect(y: menuBarHeight != nil ? min(1.0, (menuBarHeight! - 8) / 25) : 1.0, anchor: .center)
|
.scaleEffect(y: menuBarHeight != nil ? min(1.0, (menuBarHeight! - 8) / 25) : 1.0, anchor: .center)
|
||||||
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
StaticVisualizer(color: .white)
|
StaticVisualizer(color: .white)
|
||||||
.scaleEffect(y: menuBarHeight != nil ? min(1.0, (menuBarHeight! - 8) / 25) : 1.0, anchor: .center)
|
.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