Created a better visualizer and layout styling for mini recorder and notch recorder
This commit is contained in:
parent
ce5f509a68
commit
8371369b4c
113
VoiceInk/Views/Recorder/AudioVisualizerView.swift
Normal file
113
VoiceInk/Views/Recorder/AudioVisualizerView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user