Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/App/AppDelegate+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ internal extension AppDelegate {
modifiers: [.command]
))
menu.addItem(makeActionItem(
title: "Help",
title: "Help / Welcome",
selector: #selector(showHelp),
keyEquivalent: ""
))
Expand Down Expand Up @@ -146,7 +146,7 @@ internal extension AppDelegate {
return item
}

private var menuWidth: CGFloat { 300 }
private var menuWidth: CGFloat { 320 }

private static let menuTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
Expand Down
2 changes: 1 addition & 1 deletion Sources/Design/LayoutMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ internal enum LayoutMetrics {
}

enum Welcome {
static let windowSize = CGSize(width: 600, height: 720)
static let windowSize = CGSize(width: 580, height: 700)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct ClassicWaveformView: View {
let isActive: Bool
let barColor: Color

private let barCount = 64
private let barCount = 56
private let barSpacing: CGFloat = 2
private let minHeight: CGFloat = 2

Expand Down
232 changes: 232 additions & 0 deletions Sources/Views/Components/Waveform/MicTestBanner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import SwiftUI

// MARK: - Mic Test Palette

/// Coral palette used by the mic-test banner. Mirrors the design prototype;
/// `coral` matches `DashboardTheme.accent` / `coralDeep` matches `accentDeep`.
private enum MicTestPalette {
static let coral = Color(red: 0.85, green: 0.45, blue: 0.30)
static let coralDeep = Color(red: 0.70, green: 0.34, blue: 0.23)
static let coralLite = Color(red: 0.91, green: 0.60, blue: 0.47)
static let sage = Color(red: 0.37, green: 0.66, blue: 0.46)
}

// MARK: - MicTestBanner

/// "Test with my voice" banner shown at the top of the Visuals tab. Captures
/// live mic audio and drives the shared `LivePreviewSampler` so every preview
/// reacts to the user's real voice. Nothing is recorded or persisted.
internal struct MicTestBanner: View {
let style: WaveformStyle
@ObservedObject var sampler: LivePreviewSampler
@ObservedObject var capture: MicTestCapture

Comment on lines +19 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "MicTestBanner.swift" | head -20

Repository: jtn0123/AudioWhisper

Length of output: 120


🏁 Script executed:

cat -n ./Sources/Views/Components/Waveform/MicTestBanner.swift | head -30

Repository: jtn0123/AudioWhisper

Length of output: 1417


Annotate MicTestBanner with @MainActor.

This view is a UI component in Sources/**/*.swift and should be explicitly main-actor isolated.

💡 Proposed fix
-internal struct MicTestBanner: View {
+@MainActor
+internal struct MicTestBanner: View {

As per coding guidelines, Sources/**/*.swift: Use @MainActor annotation for UI components in Swift.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
internal struct MicTestBanner: View {
let style: WaveformStyle
@ObservedObject var sampler: LivePreviewSampler
@ObservedObject var capture: MicTestCapture
`@MainActor`
internal struct MicTestBanner: View {
let style: WaveformStyle
`@ObservedObject` var sampler: LivePreviewSampler
`@ObservedObject` var capture: MicTestCapture
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Views/Components/Waveform/MicTestBanner.swift` around lines 19 - 23,
The MicTestBanner SwiftUI view lacks main-actor isolation; annotate the
MicTestBanner type with `@MainActor` to ensure UI access is main-thread safe —
update the declaration of struct MicTestBanner (which holds WaveformStyle,
`@ObservedObject` var sampler: LivePreviewSampler, and `@ObservedObject` var
capture: MicTestCapture) by adding the `@MainActor` attribute so the entire view
and its lifecycle are main-actor isolated.

private var isLive: Bool { capture.state == .live }

var body: some View {
HStack(alignment: .top, spacing: 16) {
previewTile
copyColumn
Spacer(minLength: 0)
actionButton
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.white)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isLive ? MicTestPalette.coral : DashboardTheme.rule, lineWidth: isLive ? 1 : 0.5)
)
.shadow(
color: isLive ? MicTestPalette.coral.opacity(0.22) : Color.black.opacity(0.04),
radius: isLive ? 14 : 6,
y: isLive ? 0 : 2
)
}

// MARK: - Left: live preview

private var previewTile: some View {
VStack(spacing: 6) {
ZStack {
Color(red: 0.04, green: 0.04, blue: 0.04)
WaveformStylePreview(style: style, sampler: sampler)
.frame(
width: style.isRadial ? 96 : 122,
height: style.isRadial ? 96 : 64
)
}
.frame(width: 136, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))

HStack(spacing: 5) {
if isLive {
Circle()
.fill(MicTestPalette.coral)
.frame(width: 5, height: 5)
}
Text(isLive ? "Live" : "Preview")
.font(.system(size: 10, weight: .semibold))
.tracking(0.4)
.foregroundStyle(isLive ? MicTestPalette.coral : DashboardTheme.inkMuted)
}
}
}

// MARK: - Middle: copy + meter

private var copyColumn: some View {
VStack(alignment: .leading, spacing: 5) {
Text(headline)
.font(DashboardTheme.Fonts.sans(15, weight: .semibold))
.foregroundStyle(DashboardTheme.ink)

Text(subCopy)
.font(DashboardTheme.Fonts.sans(12, weight: .regular))
.foregroundStyle(DashboardTheme.inkMuted)
.fixedSize(horizontal: false, vertical: true)

if isLive {
inputMeter
.padding(.top, 4)
}
}
}

private var inputMeter: some View {
HStack(spacing: 8) {
Text("Input")
.font(.system(size: 10, weight: .semibold))
.tracking(0.4)
.textCase(.uppercase)
.foregroundStyle(DashboardTheme.inkLight)

GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(DashboardTheme.rule.opacity(0.6))
Capsule()
.fill(meterColor)
.frame(width: max(2, geo.size.width * CGFloat(min(1, capture.level))))
}
}
.frame(width: 120, height: 6)

Text("\(Int((min(1, capture.level) * 100).rounded()))%")
.font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundStyle(DashboardTheme.inkMuted)
}
}

private var meterColor: Color {
let level = capture.level
if level > 0.85 { return MicTestPalette.coralDeep }
if level > 0.5 { return MicTestPalette.coral }
return MicTestPalette.sage
}

// MARK: - Right: action button

private var actionButton: some View {
Button(action: toggle) {
HStack(spacing: 6) {
if isLive {
RoundedRectangle(cornerRadius: 2)
.fill(MicTestPalette.coral)
.frame(width: 9, height: 9)
}
Text(buttonTitle)
.font(DashboardTheme.Fonts.sans(13, weight: .semibold))
}
.foregroundStyle(buttonForeground)
.padding(.horizontal, 16)
.padding(.vertical, 9)
.background(buttonBackground)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isLive ? DashboardTheme.rule : Color.clear, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
}

@ViewBuilder
private var buttonBackground: some View {
if isLive {
RoundedRectangle(cornerRadius: 8).fill(Color.white)
} else {
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [MicTestPalette.coralLite, MicTestPalette.coral],
startPoint: .top,
endPoint: .bottom
)
)
}
}

private var buttonForeground: Color {
isLive ? DashboardTheme.ink : .white
}

private func toggle() {
if isLive {
capture.stop()
} else {
capture.start()
}
}

// MARK: - State-driven copy

private var headline: String {
switch capture.state {
case .off:
return "Test it with your voice."
case .live:
return capture.speaking ? "Looking good — keep going." : "Say something."
case .denied:
return "Microphone access blocked."
case .error:
return "Couldn't reach your microphone."
}
}

private var subCopy: String {
switch capture.state {
case .off:
return "Speak for a few seconds to see how each style responds to real "
+ "audio. The mic is only used here — nothing is sent off-device."
case .live:
return "Switch styles below — the preview reacts in real time. Nothing is recorded."
case .denied:
return "Open System Settings → Privacy & Security → Microphone and allow AudioWhisper."
case .error(let reason):
return "Mic unavailable (\(reason)). Check that no other app is using it."
}
}

private var buttonTitle: String {
switch capture.state {
case .off:
return "Test with my voice"
case .live:
return "Stop"
case .denied, .error:
return "Try again"
}
}
}

#Preview("Mic test banner") {
MicTestBanner(
style: .classic,
sampler: LivePreviewSampler(),
capture: MicTestCapture(sampler: LivePreviewSampler())
)
Comment on lines +224 to +229
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use one shared sampler in #Preview wiring.

The preview currently gives MicTestBanner one sampler and MicTestCapture a different sampler, so live capture updates won’t drive the rendered preview waveform.

💡 Proposed fix
 `#Preview`("Mic test banner") {
+    let sampler = LivePreviewSampler()
     MicTestBanner(
         style: .classic,
-        sampler: LivePreviewSampler(),
-        capture: MicTestCapture(sampler: LivePreviewSampler())
+        sampler: sampler,
+        capture: MicTestCapture(sampler: sampler)
     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Views/Components/Waveform/MicTestBanner.swift` around lines 224 -
229, The preview instantiates two separate LivePreviewSampler instances so
MicTestBanner and MicTestCapture don't share updates; create a single sampler
variable (e.g., let sampler = LivePreviewSampler()) in the `#Preview` block and
pass that same sampler to both MicTestBanner(sampler: sampler) and
MicTestCapture(sampler: sampler) so the live capture drives the rendered
waveform.

.padding(32)
.frame(width: 760)
}
Loading
Loading