Skip to content

Commit 114c96e

Browse files
authored
Add live waveform, thinking dots, and UI polish for v2.1.0 (#3)
* Add live waveform, thinking dots, and UI polish for v2.1.0 * Add coral brand color system and recolor app icon Introduce a warm, voice-centric color palette (coral #FF6B6B) to replace the generic blue/system defaults. This gives AudioType a distinct brand identity and makes recording/processing states visually distinguishable at a glance. - Add Theme.swift with centralized color constants (coral, amber, adaptive) - Tint menu bar icon: coral-red when recording, amber when processing - Recolor waveform bars from white to coral gradient - Recolor thinking dots from white to amber - Update onboarding and settings to use coral accents/checkmarks - Replace app icon with coral gradient background version * Fix swiftlint violations: force cast and opening brace spacing
1 parent 5ec4ef9 commit 114c96e

File tree

10 files changed

+248
-41
lines changed

10 files changed

+248
-41
lines changed

AudioType/App/MenuBarController.swift

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
11
import AppKit
22
import SwiftUI
33

4+
// MARK: – NSImage tinting helper
5+
6+
extension NSImage {
7+
/// Returns a copy of the image tinted with the given color (non-template).
8+
func tinted(with color: NSColor) -> NSImage {
9+
guard let tinted = self.copy() as? NSImage else { return self }
10+
tinted.isTemplate = false
11+
tinted.lockFocus()
12+
color.set()
13+
let rect = NSRect(origin: .zero, size: tinted.size)
14+
rect.fill(using: .sourceAtop)
15+
tinted.unlockFocus()
16+
return tinted
17+
}
18+
}
19+
20+
/// Shared observable for live audio level — drives the recording waveform.
21+
class AudioLevelMonitor: ObservableObject {
22+
static let shared = AudioLevelMonitor()
23+
@Published var level: Float = 0.0
24+
}
25+
426
class MenuBarController: NSObject, NSWindowDelegate {
527
private weak var statusItem: NSStatusItem?
628
private var transcriptionManager: TranscriptionManager
@@ -18,6 +40,14 @@ class MenuBarController: NSObject, NSWindowDelegate {
1840
name: .transcriptionStateChanged,
1941
object: nil
2042
)
43+
44+
// Observe audio level changes
45+
NotificationCenter.default.addObserver(
46+
self,
47+
selector: #selector(audioLevelDidChange),
48+
name: .audioLevelChanged,
49+
object: nil
50+
)
2151
}
2252

2353
func setupStatusItem(_ statusItem: NSStatusItem) {
@@ -64,36 +94,53 @@ class MenuBarController: NSObject, NSWindowDelegate {
6494
}
6595
}
6696

97+
@objc private func audioLevelDidChange(_ notification: Notification) {
98+
guard let level = notification.userInfo?["level"] as? Float else { return }
99+
DispatchQueue.main.async {
100+
AudioLevelMonitor.shared.level = level
101+
}
102+
}
103+
67104
private func updateUI(for state: TranscriptionState) {
68105
guard let button = statusItem?.button else { return }
69106

70107
switch state {
71108
case .idle:
72-
button.image = NSImage(
109+
let img = NSImage(
73110
systemSymbolName: "waveform.circle.fill", accessibilityDescription: "Ready")
111+
img?.isTemplate = true
112+
button.image = img
113+
AudioLevelMonitor.shared.level = 0
74114
hideRecordingIndicator()
75115
updateStatusMenuItem("Ready")
76116

77117
case .recording:
78-
button.image = NSImage(
79-
systemSymbolName: "waveform.circle.fill", accessibilityDescription: "Recording")
118+
// Tinted coral/red — non-template so the color shows through
119+
if let base = NSImage(
120+
systemSymbolName: "waveform.circle.fill", accessibilityDescription: "Recording") {
121+
button.image = base.tinted(with: AudioTypeTheme.nsRecordingRed)
122+
}
80123
showRecordingIndicator()
81124
updateStatusMenuItem("Recording...")
82125

83126
case .processing:
84-
button.image = NSImage(
85-
systemSymbolName: "ellipsis.circle.fill", accessibilityDescription: "Processing")
127+
// Tinted amber — "I'm thinking"
128+
if let base = NSImage(
129+
systemSymbolName: "ellipsis.circle.fill", accessibilityDescription: "Processing") {
130+
button.image = base.tinted(with: AudioTypeTheme.nsAmber)
131+
}
132+
AudioLevelMonitor.shared.level = 0
86133
updateRecordingIndicator(text: "Processing...")
87134
updateStatusMenuItem("Processing...")
88135

89136
case .error(let message):
90-
button.image = NSImage(
137+
let img = NSImage(
91138
systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Error")
139+
img?.isTemplate = false
140+
button.image = img?.tinted(with: .systemRed)
92141
hideRecordingIndicator()
93142
updateStatusMenuItem("Error: \(message)")
94143
}
95-
96-
button.image?.isTemplate = true
97144
}
98145

99146
private func updateStatusMenuItem(_ text: String) {
@@ -128,16 +175,19 @@ class MenuBarController: NSObject, NSWindowDelegate {
128175
recordingWindow = window
129176
}
130177

131-
// Always update content to "Recording..." when showing
132-
let hostingView = NSHostingView(rootView: RecordingOverlay(text: "Recording..."))
178+
let hostingView = NSHostingView(
179+
rootView: RecordingOverlay(text: "Recording...")
180+
.environmentObject(AudioLevelMonitor.shared))
133181
hostingView.frame = NSRect(x: 0, y: 0, width: 180, height: 50)
134182
recordingWindow?.contentView = hostingView
135183
recordingWindow?.orderFront(nil)
136184
}
137185

138186
private func updateRecordingIndicator(text: String) {
139187
if let window = recordingWindow {
140-
let hostingView = NSHostingView(rootView: RecordingOverlay(text: text))
188+
let hostingView = NSHostingView(
189+
rootView: RecordingOverlay(text: text)
190+
.environmentObject(AudioLevelMonitor.shared))
141191
hostingView.frame = NSRect(x: 0, y: 0, width: 180, height: 50)
142192
window.contentView = hostingView
143193
}
@@ -185,4 +235,5 @@ class MenuBarController: NSObject, NSWindowDelegate {
185235

186236
extension Notification.Name {
187237
static let transcriptionStateChanged = Notification.Name("transcriptionStateChanged")
238+
static let audioLevelChanged = Notification.Name("audioLevelChanged")
188239
}

AudioType/App/TranscriptionManager.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class TranscriptionManager: ObservableObject {
2525

2626
@Published private(set) var state: TranscriptionState = .idle
2727
@Published private(set) var isInitialized = false
28+
@Published private(set) var audioLevel: Float = 0.0
2829

2930
private var groqEngine: GroqEngine?
3031
private var audioRecorder: AudioRecorder?
@@ -40,6 +41,16 @@ class TranscriptionManager: ObservableObject {
4041

4142
// Initialize components
4243
audioRecorder = AudioRecorder()
44+
audioRecorder?.onLevelUpdate = { [weak self] level in
45+
Task { @MainActor in
46+
self?.audioLevel = level
47+
NotificationCenter.default.post(
48+
name: .audioLevelChanged,
49+
object: nil,
50+
userInfo: ["level": level]
51+
)
52+
}
53+
}
4354
textInserter = TextInserter()
4455

4556
// Initialize Groq engine (lightweight — no model download needed)
@@ -149,6 +160,13 @@ class TranscriptionManager: ObservableObject {
149160
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
150161
logger.info("Transcription completed in \(elapsed, format: .fixed(precision: 2))s: \(text)")
151162

163+
// Ensure processing indicator is visible for at least 0.5s
164+
let minDisplayTime = 0.5
165+
let remaining = minDisplayTime - elapsed
166+
if remaining > 0 {
167+
try? await Task.sleep(for: .seconds(remaining))
168+
}
169+
152170
// Post-process and insert text with trailing space
153171
await MainActor.run {
154172
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)

AudioType/Core/AudioRecorder.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ class AudioRecorder {
77
private let bufferLock = NSLock()
88
private var isRecording = false
99

10+
/// Current audio level (0.0–1.0), updated in real-time from the mic input.
11+
var onLevelUpdate: ((Float) -> Void)?
12+
1013
private let logger = Logger(subsystem: "com.audiotype", category: "AudioRecorder")
1114

1215
// Whisper requires 16kHz mono audio
@@ -135,6 +138,12 @@ class AudioRecorder {
135138
UnsafeBufferPointer(start: channelData[0], count: Int(buffer.frameLength)))
136139
}
137140

141+
// Compute RMS level for live waveform
142+
let rms = sqrt(samplesArray.reduce(0) { $0 + $1 * $1 } / Float(max(samplesArray.count, 1)))
143+
// Normalize: typical speech RMS is 0.01–0.15, scale aggressively to 0–1
144+
let level = min(rms * 25, 1.0)
145+
onLevelUpdate?(level)
146+
138147
// Append to buffer
139148
bufferLock.lock()
140149
audioBuffer.append(contentsOf: samplesArray)

AudioType/UI/OnboardingView.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ struct OnboardingView: View {
1919
VStack(spacing: 8) {
2020
Image(systemName: "mic.fill")
2121
.font(.system(size: 48))
22-
.foregroundColor(.accentColor)
22+
.foregroundColor(AudioTypeTheme.coral)
2323

2424
Text("Welcome to AudioType")
2525
.font(.title)
@@ -56,7 +56,7 @@ struct OnboardingView: View {
5656
HStack(spacing: 12) {
5757
Image(systemName: "key.fill")
5858
.font(.title2)
59-
.foregroundColor(.accentColor)
59+
.foregroundColor(AudioTypeTheme.coral)
6060
.frame(width: 32)
6161

6262
VStack(alignment: .leading, spacing: 2) {
@@ -71,7 +71,7 @@ struct OnboardingView: View {
7171

7272
if apiKeyConfigured {
7373
Image(systemName: "checkmark.circle.fill")
74-
.foregroundColor(.green)
74+
.foregroundColor(AudioTypeTheme.coral)
7575
}
7676
}
7777

@@ -117,6 +117,7 @@ struct OnboardingView: View {
117117
.frame(maxWidth: .infinity)
118118
}
119119
.buttonStyle(.borderedProminent)
120+
.tint(AudioTypeTheme.coral)
120121
.controlSize(.large)
121122
.disabled(!canContinue)
122123
.padding(.horizontal)
@@ -187,7 +188,7 @@ struct PermissionRow: View {
187188
HStack(spacing: 12) {
188189
Image(systemName: icon)
189190
.font(.title2)
190-
.foregroundColor(.accentColor)
191+
.foregroundColor(AudioTypeTheme.coral)
191192
.frame(width: 32)
192193

193194
VStack(alignment: .leading, spacing: 2) {
@@ -202,7 +203,7 @@ struct PermissionRow: View {
202203

203204
if isGranted {
204205
Image(systemName: "checkmark.circle.fill")
205-
.foregroundColor(.green)
206+
.foregroundColor(AudioTypeTheme.coral)
206207
} else {
207208
Button("Grant") {
208209
action()

AudioType/UI/RecordingOverlay.swift

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,87 @@ import SwiftUI
22

33
struct RecordingOverlay: View {
44
let text: String
5+
@EnvironmentObject var levelMonitor: AudioLevelMonitor
56

67
private var isRecording: Bool {
78
text == "Recording..."
89
}
910

1011
var body: some View {
11-
HStack(spacing: 10) {
12+
HStack(spacing: 8) {
1213
if isRecording {
13-
// Pulsing red dot for recording
14-
Circle()
15-
.fill(Color.red)
16-
.frame(width: 10, height: 10)
14+
LiveWaveformView(level: levelMonitor.level)
15+
.frame(width: 44, height: 24)
1716
} else {
18-
// Spinner for processing
19-
ProgressView()
20-
.scaleEffect(0.7)
21-
.frame(width: 10, height: 10)
17+
ThinkingDotsView()
18+
.frame(width: 40, height: 20)
2219
}
23-
24-
Text(isRecording ? "Recording" : "Processing")
25-
.font(.system(size: 13, weight: .medium))
26-
.foregroundColor(.primary)
27-
.fixedSize(horizontal: true, vertical: false)
2820
}
2921
.padding(.horizontal, 16)
3022
.padding(.vertical, 10)
31-
.frame(minWidth: 140)
23+
.frame(minWidth: 80)
3224
.background(
3325
RoundedRectangle(cornerRadius: 10)
3426
.fill(.ultraThinMaterial)
3527
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
3628
)
3729
}
3830
}
31+
32+
/// Waveform bars driven by live audio level from the microphone.
33+
struct LiveWaveformView: View {
34+
var level: Float
35+
36+
private let barCount = 5
37+
private let minHeight: CGFloat = 3
38+
private let maxHeight: CGFloat = 22
39+
40+
var body: some View {
41+
HStack(spacing: 3) {
42+
ForEach(0..<barCount, id: \.self) { index in
43+
RoundedRectangle(cornerRadius: 1.5)
44+
.fill(
45+
LinearGradient(
46+
colors: [AudioTypeTheme.coral, AudioTypeTheme.coralLight],
47+
startPoint: .bottom,
48+
endPoint: .top
49+
)
50+
)
51+
.frame(width: 3, height: barHeight(for: index))
52+
.animation(.easeOut(duration: 0.08), value: level)
53+
}
54+
}
55+
}
56+
57+
private func barHeight(for index: Int) -> CGFloat {
58+
// Each bar gets a slightly different scale to look organic
59+
let offsets: [Float] = [0.6, 1.0, 0.8, 0.9, 0.5]
60+
let scaled = CGFloat(level * offsets[index % offsets.count])
61+
return max(minHeight, minHeight + (maxHeight - minHeight) * scaled)
62+
}
63+
}
64+
65+
struct ThinkingDotsView: View {
66+
@State private var animating = false
67+
68+
var body: some View {
69+
HStack(spacing: 6) {
70+
ForEach(0..<3, id: \.self) { index in
71+
Circle()
72+
.fill(AudioTypeTheme.thinkingColor)
73+
.frame(width: 6, height: 6)
74+
.scaleEffect(animating ? 1.0 : 0.4)
75+
.opacity(animating ? 1.0 : 0.3)
76+
.animation(
77+
.easeInOut(duration: 0.5)
78+
.repeatForever(autoreverses: true)
79+
.delay(Double(index) * 0.2),
80+
value: animating
81+
)
82+
}
83+
}
84+
.onAppear {
85+
animating = true
86+
}
87+
}
88+
}

AudioType/UI/SettingsView.swift

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ struct SettingsView: View {
3131
if isApiKeySet {
3232
HStack(spacing: 4) {
3333
Image(systemName: "checkmark.circle.fill")
34-
.foregroundColor(.green)
34+
.foregroundColor(AudioTypeTheme.coral)
3535
.font(.caption)
3636
Text("API key configured")
3737
.foregroundColor(.secondary)
@@ -45,12 +45,14 @@ struct SettingsView: View {
4545
.font(.caption)
4646
}
4747

48-
Button("Get free API key") {
49-
if let url = URL(string: "https://console.groq.com/keys") {
50-
NSWorkspace.shared.open(url)
48+
if !isApiKeySet {
49+
Button("Get free API key") {
50+
if let url = URL(string: "https://console.groq.com/keys") {
51+
NSWorkspace.shared.open(url)
52+
}
5153
}
54+
.font(.caption)
5255
}
53-
.font(.caption)
5456

5557
Picker("Model", selection: $selectedModel) {
5658
ForEach(GroqModel.allCases, id: \.self) { model in
@@ -99,7 +101,7 @@ struct SettingsView: View {
99101
HStack {
100102
Text("Version")
101103
Spacer()
102-
Text("2.0.0")
104+
Text("2.1.0")
103105
.foregroundColor(.secondary)
104106
}
105107

@@ -154,7 +156,7 @@ struct PermissionStatusView: View {
154156
var body: some View {
155157
HStack(spacing: 4) {
156158
Image(systemName: granted ? "checkmark.circle.fill" : "xmark.circle.fill")
157-
.foregroundColor(granted ? .green : .red)
159+
.foregroundColor(granted ? AudioTypeTheme.coral : .red)
158160
Text(granted ? "Granted" : "Not Granted")
159161
.foregroundColor(.secondary)
160162
}

0 commit comments

Comments
 (0)