Created a better visualizer and layout styling for mini recorder and notch recorder

This commit is contained in:
Beingpax 2025-05-25 17:51:23 +05:45
parent ce5f509a68
commit 8371369b4c
9 changed files with 161 additions and 273 deletions

View File

@ -0,0 +1,113 @@
import SwiftUI
struct AudioVisualizer: View {
let audioMeter: AudioMeter
let color: Color
let isActive: Bool
private let barCount = 12
private let minHeight: CGFloat = 4
private let maxHeight: CGFloat = 28
private let barWidth: CGFloat = 3.0
private let barSpacing: CGFloat = 2.0
private let hardThreshold: Double = 0.3
private let sensitivityMultipliers: [Double]
@State private var barHeights: [CGFloat]
@State private var targetHeights: [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))
}
var body: some View {
HStack(spacing: barSpacing) {
ForEach(0..<barCount, id: \.self) { index in
RoundedRectangle(cornerRadius: 1.5)
.fill(color)
.frame(width: barWidth, height: barHeights[index])
}
}
.onChange(of: audioMeter) { _, newValue in
if isActive {
updateBars(with: Float(newValue.averagePower))
} else {
resetBars()
}
}
.onChange(of: isActive) { _, newValue in
if !newValue {
resetBars()
}
}
}
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)
}
}
}
struct StaticVisualizer: View {
private let barCount = 12
private let barWidth: CGFloat = 3.0
private let staticHeight: CGFloat = 3.0
private let barSpacing: CGFloat = 2.0
let color: Color
var body: some View {
HStack(spacing: barSpacing) {
ForEach(0..<barCount, id: \.self) { index in
RoundedRectangle(cornerRadius: 1.5)
.fill(color)
.frame(width: barWidth, height: staticHeight)
}
}
}
}

View File

@ -39,36 +39,33 @@ struct MiniRecorderView: View {
.strokeBorder(Color.white.opacity(0.1), lineWidth: 0.5)
}
.overlay {
HStack(spacing: 16) {
// Record Button
HStack(spacing: 0) {
// Record Button - on the left
NotchRecordButton(
isRecording: whisperState.isRecording,
isProcessing: whisperState.isProcessing
) {
Task { await whisperState.toggleRecord() }
}
.frame(width: 18)
.padding(.leading, -4)
.frame(width: 24)
.padding(.leading, 8)
// Visualizer - moved to middle position
// Visualizer - centered and expanded
Group {
if whisperState.isProcessing {
NotchStaticVisualizer(color: .white)
StaticVisualizer(color: .white)
} else {
NotchAudioVisualizer(
AudioVisualizer(
audioMeter: recorder.audioMeter,
color: .white,
isActive: whisperState.isRecording
)
}
}
.frame(width: 18)
.frame(maxWidth: .infinity)
.padding(.horizontal, 8)
// Empty space for future use
Spacer()
.frame(width: 18)
// Power Mode Button - moved to last position
// Power Mode Button - on the right
NotchToggleButton(
isEnabled: powerModeManager.isPowerModeEnabled,
icon: powerModeManager.currentActiveConfiguration.emoji,
@ -76,13 +73,12 @@ struct MiniRecorderView: View {
) {
showPowerModePopover.toggle()
}
.frame(width: 18)
.padding(.trailing, -4)
.frame(width: 24)
.padding(.trailing, 8)
.popover(isPresented: $showPowerModePopover, arrowEdge: .bottom) {
PowerModePopover()
}
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
.opacity(windowManager.isVisible ? 1 : 0)

View File

@ -29,7 +29,7 @@ class NotchRecorderPanel: KeyablePanel {
// Calculate total width including controls and padding
// 16pt padding on each side + space for controls
let controlsWidth: CGFloat = 44 // Space for buttons on each side (22 * 2)
let controlsWidth: CGFloat = 64 // Space for buttons on each side (increased width)
let paddingWidth: CGFloat = 32 // 16pt on each side
let totalWidth = baseNotchWidth + controlsWidth * 2 + paddingWidth
@ -101,7 +101,7 @@ class NotchRecorderPanel: KeyablePanel {
let baseNotchWidth: CGFloat = safeAreaInsets.left > 0 ? safeAreaInsets.left * 2 : 200
// Calculate total width including controls and padding
let controlsWidth: CGFloat = 44 // Space for buttons on each side (22 * 2)
let controlsWidth: CGFloat = 64 // Space for buttons on each side (increased width)
let paddingWidth: CGFloat = 32 // 16pt on each side
let totalWidth = baseNotchWidth + controlsWidth * 2 + paddingWidth

View File

@ -47,36 +47,7 @@ struct NotchRecorderView: View {
}
.frame(width: 22)
// Empty space for future use
Spacer()
.frame(width: 22)
}
.frame(width: 44) // Fixed width for controls
.padding(.leading, 16)
// Center section with exact notch width
Rectangle()
.fill(Color.clear)
.frame(width: exactNotchWidth)
.contentShape(Rectangle()) // Make the entire area tappable
// Right side group with fixed width
HStack(spacing: 8) {
// Visualizer - moved to first position
Group {
if whisperState.isProcessing {
NotchStaticVisualizer(color: .white)
} else {
NotchAudioVisualizer(
audioMeter: recorder.audioMeter,
color: .white,
isActive: whisperState.isRecording
)
}
}
.frame(width: 22)
// Power Mode Button - moved to second position
// Power Mode Button - moved from right side
NotchToggleButton(
isEnabled: powerModeManager.isPowerModeEnabled,
icon: powerModeManager.currentActiveConfiguration.emoji,
@ -88,8 +59,40 @@ struct NotchRecorderView: View {
.popover(isPresented: $showPowerModePopover, arrowEdge: .bottom) {
PowerModePopover()
}
Spacer()
}
.frame(width: 44) // Fixed width for controls
.frame(width: 64) // Increased width for both controls
.padding(.leading, 16)
// Center section with exact notch width
Rectangle()
.fill(Color.clear)
.frame(width: exactNotchWidth)
.contentShape(Rectangle()) // Make the entire area tappable
// Right side group with visualizer only
HStack(spacing: 0) {
Spacer() // Push visualizer to the right
// Visualizer - contained within right area with scaling
Group {
if whisperState.isProcessing {
StaticVisualizer(color: .white)
} else {
AudioVisualizer(
audioMeter: recorder.audioMeter,
color: .white,
isActive: whisperState.isRecording
)
// Apply a vertical scale transform to fit within the menu bar
.scaleEffect(y: min(1.0, (menuBarHeight - 8) / 25), anchor: .center)
}
}
.frame(width: 30)
.padding(.trailing, 8) // Add padding to keep it away from the edge
}
.frame(width: 64) // Increased width to match left side
.padding(.trailing, 16)
}
.frame(height: menuBarHeight)
@ -248,139 +251,6 @@ struct NotchRecordButton: View {
}
}
struct NotchAudioVisualizer: View {
let audioMeter: AudioMeter
let color: Color
let isActive: Bool
private let barCount = 5
private let minHeight: CGFloat = 3
private let maxHeight: CGFloat = 18
private let audioThreshold: CGFloat = 0.01
@State private var barHeights: [BarLevel] = []
struct BarLevel {
var average: CGFloat
var peak: CGFloat
}
init(audioMeter: AudioMeter, color: Color, isActive: Bool) {
self.audioMeter = audioMeter
self.color = color
self.isActive = isActive
_barHeights = State(initialValue: Array(repeating: BarLevel(average: minHeight, peak: minHeight), count: 5))
}
var body: some View {
HStack(spacing: 2) {
ForEach(0..<barCount, id: \.self) { index in
NotchVisualizerBar(
averageHeight: barHeights[index].average,
peakHeight: barHeights[index].peak,
color: color
)
}
}
.onChange(of: audioMeter) { oldValue, newValue in
if isActive {
updateBars()
} else {
resetBars()
}
}
}
private func updateBars() {
for i in 0..<barCount {
let targetHeight = calculateTargetHeight(for: i)
let speed = CGFloat.random(in: 0.4...0.8)
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) {
barHeights[i].average += (targetHeight.average - barHeights[i].average) * speed
barHeights[i].peak += (targetHeight.peak - barHeights[i].peak) * speed
}
}
}
private func resetBars() {
withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) {
for i in 0..<barCount {
barHeights[i].average = minHeight
barHeights[i].peak = minHeight
}
}
}
private func calculateTargetHeight(for index: Int) -> BarLevel {
let positionFactor = CGFloat(index) / CGFloat(barCount - 1)
let curve = sin(positionFactor * .pi)
let randomFactor = Double.random(in: 0.8...1.2)
let averageBase = audioMeter.averagePower * randomFactor
let peakBase = audioMeter.peakPower * randomFactor
let averageHeight = CGFloat(averageBase) * maxHeight * 1.7 * curve
let peakHeight = CGFloat(peakBase) * maxHeight * 1.7 * curve
let finalAverage = max(minHeight, min(averageHeight, maxHeight))
let finalPeak = max(minHeight, min(peakHeight, maxHeight))
return BarLevel(
average: finalAverage,
peak: finalPeak
)
}
}
struct NotchVisualizerBar: View {
let averageHeight: CGFloat
let peakHeight: CGFloat
let color: Color
var body: some View {
ZStack(alignment: .bottom) {
// Average level bar
RoundedRectangle(cornerRadius: 1.5)
.fill(
LinearGradient(
gradient: Gradient(colors: [
color.opacity(0.6),
color.opacity(0.8),
color
]),
startPoint: .bottom,
endPoint: .top
)
)
.frame(width: 2, height: averageHeight)
}
.animation(.spring(response: 0.2, dampingFraction: 0.7, blendDuration: 0), value: averageHeight)
.animation(.spring(response: 0.2, dampingFraction: 0.7, blendDuration: 0), value: peakHeight)
}
}
struct NotchStaticVisualizer: View {
private let barCount = 5
private let barHeights: [CGFloat] = [0.7, 0.5, 0.8, 0.4, 0.6]
let color: Color
var body: some View {
HStack(spacing: 2) {
ForEach(0..<barCount, id: \.self) { index in
NotchVisualizerBar(
averageHeight: barHeights[index] * 18,
peakHeight: barHeights[index] * 18,
color: color
)
}
}
}
}
struct ProcessingIndicator: View {
@State private var rotation: Double = 0
let color: Color

View File

@ -1,91 +0,0 @@
import SwiftUI
struct VisualizerView: View {
@ObservedObject var recorder: Recorder
private let barCount = 50
@State private var levels: [BarLevel] = []
private let smoothingFactor: Double = 0.3
struct BarLevel: Equatable {
var average: CGFloat
var peak: CGFloat
}
var body: some View {
GeometryReader { geometry in
HStack(alignment: .center, spacing: 4) {
ForEach(0..<barCount, id: \.self) { index in
VisualizerBar(level: levels.isEmpty ? BarLevel(average: 0, peak: 0) : levels[index])
.frame(width: (geometry.size.width - CGFloat(barCount - 1) * 4) / CGFloat(barCount))
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
.background(Color.black.opacity(0.1))
.cornerRadius(10)
.onAppear {
levels = Array(repeating: BarLevel(average: 0, peak: 0), count: barCount)
}
.onReceive(recorder.$audioMeter) { newMeter in
updateLevels(with: newMeter)
}
}
}
private func updateLevels(with meter: AudioMeter) {
// Create new levels with randomization for visual interest
var newLevels: [BarLevel] = []
for i in 0..<barCount {
let randomFactor = Double.random(in: 0.8...1.2)
let targetAverage = min(max(meter.averagePower * randomFactor, 0), 1)
let targetPeak = min(max(meter.peakPower * randomFactor, 0), 1)
let currentLevel = levels[i]
let smoothedAverage = currentLevel.average + (CGFloat(targetAverage) - currentLevel.average) * CGFloat(smoothingFactor)
let smoothedPeak = currentLevel.peak + (CGFloat(targetPeak) - currentLevel.peak) * CGFloat(smoothingFactor)
newLevels.append(BarLevel(
average: smoothedAverage,
peak: smoothedPeak
))
}
withAnimation(.easeInOut(duration: 0.15)) {
levels = newLevels
}
}
}
struct VisualizerBar: View {
let level: VisualizerView.BarLevel
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .bottom) {
// Average level bar
RoundedRectangle(cornerRadius: 2)
.fill(
LinearGradient(
gradient: Gradient(colors: [.blue.opacity(0.7), .purple.opacity(0.7)]),
startPoint: .bottom,
endPoint: .top
)
)
.frame(height: level.average * geometry.size.height)
// Peak level indicator
RoundedRectangle(cornerRadius: 2)
.fill(
LinearGradient(
gradient: Gradient(colors: [.blue, .purple]),
startPoint: .bottom,
endPoint: .top
)
)
.frame(height: 2)
.offset(y: -level.peak * geometry.size.height + 1)
.opacity(level.peak > 0.01 ? 1 : 0)
}
.frame(maxHeight: geometry.size.height, alignment: .bottom)
}
}
}