diff --git a/Sources/App/AppDelegate+Menu.swift b/Sources/App/AppDelegate+Menu.swift index df1515d..f92fe84 100644 --- a/Sources/App/AppDelegate+Menu.swift +++ b/Sources/App/AppDelegate+Menu.swift @@ -66,7 +66,7 @@ internal extension AppDelegate { modifiers: [.command] )) menu.addItem(makeActionItem( - title: "Help", + title: "Help / Welcome", selector: #selector(showHelp), keyEquivalent: "" )) @@ -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() diff --git a/Sources/Design/LayoutMetrics.swift b/Sources/Design/LayoutMetrics.swift index 37e4fe1..de37d46 100644 --- a/Sources/Design/LayoutMetrics.swift +++ b/Sources/Design/LayoutMetrics.swift @@ -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) } } diff --git a/Sources/Views/Components/Waveform/ClassicWaveformView.swift b/Sources/Views/Components/Waveform/ClassicWaveformView.swift index 6d29307..815d80d 100644 --- a/Sources/Views/Components/Waveform/ClassicWaveformView.swift +++ b/Sources/Views/Components/Waveform/ClassicWaveformView.swift @@ -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 diff --git a/Sources/Views/Components/Waveform/MicTestBanner.swift b/Sources/Views/Components/Waveform/MicTestBanner.swift new file mode 100644 index 0000000..8f1bd91 --- /dev/null +++ b/Sources/Views/Components/Waveform/MicTestBanner.swift @@ -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 + + 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()) + ) + .padding(32) + .frame(width: 760) +} diff --git a/Sources/Views/Components/Waveform/MicTestCapture.swift b/Sources/Views/Components/Waveform/MicTestCapture.swift new file mode 100644 index 0000000..16ea7e0 --- /dev/null +++ b/Sources/Views/Components/Waveform/MicTestCapture.swift @@ -0,0 +1,195 @@ +import Accelerate +import AVFoundation +import Foundation +import os.log + +// MARK: - MicTestState + +/// State of the live mic test in the Visuals tab. +internal enum MicTestState: Equatable { + case off + case live + case denied + case error(String) +} + +// MARK: - MicTestCapture + +/// Captures live microphone audio for the "Test with my voice" banner and +/// feeds the derived level / samples / bands into a `LivePreviewSampler`. +/// +/// Nothing is recorded or persisted — capture is visualization-only. The +/// engine + mic are released as soon as the test is stopped or fails. +@MainActor +final class MicTestCapture: ObservableObject { + // MARK: - Published State + + @Published private(set) var state: MicTestState = .off + @Published private(set) var level: Float = 0 + @Published private(set) var speaking: Bool = false + + // MARK: - Dependencies + + private weak var sampler: LivePreviewSampler? + + // MARK: - Audio Engine + + private var audioEngine: AVAudioEngine? + private let fftProcessor = FFTProcessor() + + /// Smoothed audio level — mutated on the audio thread, read for publishing. + private nonisolated(unsafe) var smoothedLevel: Float = 0 + private let stateLock = NSLock() + + // MARK: - Lifecycle + + init(sampler: LivePreviewSampler) { + self.sampler = sampler + } + + deinit { + audioEngine?.inputNode.removeTap(onBus: 0) + audioEngine?.stop() + } + + // MARK: - Control + + /// Begin (or retry) live mic capture, requesting permission as needed. + func start() { + guard state != .live else { return } + + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + beginCapture() + case .denied, .restricted: + state = .denied + case .notDetermined: + AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in + Task { @MainActor [weak self] in + guard let self = self else { return } + if granted { + self.beginCapture() + } else { + self.state = .denied + } + } + } + @unknown default: + state = .error("permission status unknown") + } + } + + /// Stop capture, release the mic, and return the sampler to synthetic mode. + func stop() { + teardownEngine() + smoothedLevel = 0 + level = 0 + speaking = false + if state == .live { state = .off } + sampler?.setMicMode(false) + } + + // MARK: - Private + + private func beginCapture() { + guard audioEngine == nil else { return } + + // Real audio hardware is unavailable / undesirable under tests. + if AppEnvironment.isRunningTests { + state = .error("test environment") + return + } + + let engine = AVAudioEngine() + let inputNode = engine.inputNode + let format = inputNode.outputFormat(forBus: 0) + + guard format.sampleRate > 0, format.channelCount > 0 else { + state = .error("no input device") + return + } + + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.handleBuffer(buffer) + } + + do { + try engine.start() + } catch { + engine.inputNode.removeTap(onBus: 0) + Logger.micTest.error("Failed to start mic test engine: \(error.localizedDescription)") + state = .error(error.localizedDescription) + return + } + + audioEngine = engine + sampler?.setMicMode(true) + state = .live + } + + private func teardownEngine() { + if let engine = audioEngine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + audioEngine = nil + } + + /// Called on the AVAudioEngine audio thread. + nonisolated private func handleBuffer(_ buffer: AVAudioPCMBuffer) { + guard let channelData = buffer.floatChannelData else { return } + + let frameLength = Int(buffer.frameLength) + guard frameLength > 0 else { return } + + let mono = Array(UnsafeBufferPointer(start: channelData[0], count: frameLength)) + + // RMS level — amplified + smoothed per the design spec. + var rms: Float = 0 + vDSP_rmsqv(mono, 1, &rms, vDSP_Length(frameLength)) + let target = max(0.05, min(1, rms * 5)) + let smooth = smoothedLevel * 0.55 + target * 0.45 + smoothedLevel = smooth + + // 64 time-domain samples scaled by 0.9. + let samples = Self.downsample(mono, count: 64).map { $0 * 0.9 } + + // 8 frequency bands, ignoring the top ~35% of the range as noise. + let rawBands = fftProcessor?.process(mono) ?? Array(repeating: 0, count: 8) + let bands = rawBands.map { max(0.05, min(1, $0 * 1.8)) } + + let isSpeaking = smooth > 0.22 + + Task { @MainActor [weak self] in + guard let self = self else { return } + self.level = smooth + self.speaking = isSpeaking + self.sampler?.publishMicFrame(level: smooth, samples: samples, bands: bands) + } + } + + /// Average-pool `source` down to `count` evenly-spaced samples. + nonisolated private static func downsample(_ source: [Float], count: Int) -> [Float] { + guard count > 0 else { return [] } + guard source.count > count else { + return source + Array(repeating: 0, count: count - source.count) + } + let chunk = source.count / count + return (0.. 0.55 && (time - lastPeakAt) > 0.4 { + lastPeakAt = time + } + sincePeak = time - lastPeakAt + } + private func tick() { + // In mic mode the real audio frames drive the published values. + guard !isMicMode else { return } time += 0.033 // Envelope — slow varying diff --git a/Sources/Views/Dashboard/DashboardHomeView.swift b/Sources/Views/Dashboard/DashboardHomeView.swift index c04df9d..cdbdb75 100644 --- a/Sources/Views/Dashboard/DashboardHomeView.swift +++ b/Sources/Views/Dashboard/DashboardHomeView.swift @@ -130,7 +130,7 @@ extension DashboardHomeView { HStack(alignment: .firstTextBaseline, spacing: 10) { Text(formatNumber(metricsStore.snapshot.totalWords)) - .font(DashboardTheme.Fonts.serif(52, weight: .medium)) + .font(DashboardTheme.Fonts.serif(48, weight: .medium)) .monospacedDigit() .tracking(-1.5) .foregroundStyle(DashboardTheme.ink) diff --git a/Sources/Views/Dashboard/DashboardView.swift b/Sources/Views/Dashboard/DashboardView.swift index a0e2f20..d778041 100644 --- a/Sources/Views/Dashboard/DashboardView.swift +++ b/Sources/Views/Dashboard/DashboardView.swift @@ -162,6 +162,7 @@ internal enum DashboardNavItem: String, CaseIterable, Identifiable { internal struct DashboardView: View { @State private var selectedNav: DashboardNavItem = .dashboard @State private var metricsStore = UsageMetricsStore.shared + @State private var showBreakdown = false var body: some View { HStack(spacing: 0) { @@ -308,10 +309,118 @@ internal struct DashboardView: View { .font(DashboardTheme.Fonts.sans(11, weight: .regular)) .foregroundStyle(DashboardTheme.sidebarTextMuted) } + + if let delta = monthDeltaPercent { + HStack(spacing: 6) { + Text("\(delta >= 0 ? "↑" : "↓") \(abs(delta))%") + .font(DashboardTheme.Fonts.sans(10.5, weight: .semibold)) + .foregroundStyle(delta >= 0 ? DashboardTheme.success : DashboardTheme.accentDeep) + Text("this month") + .font(DashboardTheme.Fonts.sans(10.5, weight: .regular)) + .foregroundStyle(DashboardTheme.sidebarTextMuted) + } + .padding(.top, 2) + } } .padding(.horizontal, DashboardTheme.Spacing.md) .padding(.vertical, DashboardTheme.Spacing.md) } + .contentShape(Rectangle()) + .onHover { showBreakdown = $0 } + .overlay(alignment: .bottomLeading) { + if showBreakdown { + breakdownPopover + .offset(x: LayoutMetrics.DashboardWindow.sidebarWidth + 8, y: -8) + .allowsHitTesting(false) + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.12), value: showBreakdown) + } + + /// Hover popover: a lifetime breakdown of recording activity. + private var breakdownPopover: some View { + let ink = Color(red: 0.102, green: 0.086, blue: 0.071) // #1A1612 — always dark + return VStack(alignment: .leading, spacing: 3) { + Text("Lifetime breakdown") + .font(DashboardTheme.Fonts.sans(10, weight: .semibold)) + .tracking(0.6) + .textCase(.uppercase) + .foregroundStyle(.white.opacity(0.55)) + .padding(.bottom, 3) + + popRow("This month", DashboardHomeView.numberString(wordsInLast(28))) + popRow("Last 7 days", DashboardHomeView.numberString(wordsInLast(7))) + popRow("Avg / day", DashboardHomeView.numberString(dailyAverage)) + + Rectangle() + .fill(.white.opacity(0.12)) + .frame(height: 1) + .padding(.vertical, 3) + + popRow("Time saved", DashboardHomeView.durationString(metricsStore.snapshot.estimatedTimeSaved), subtle: true) + popRow("Sessions", DashboardHomeView.numberString(metricsStore.snapshot.totalSessions), subtle: true) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(width: 220) + .background(RoundedRectangle(cornerRadius: 8).fill(ink)) + .shadow(color: .black.opacity(0.3), radius: 14, y: 8) + } + + private func popRow(_ label: String, _ value: String, subtle: Bool = false) -> some View { + HStack { + Text(label) + .font(DashboardTheme.Fonts.sans(11, weight: .regular)) + .foregroundStyle(.white.opacity(0.7)) + Spacer() + Text(value) + .font(DashboardTheme.Fonts.mono(11, weight: .regular)) + .monospacedDigit() + .foregroundStyle(.white) + } + .opacity(subtle ? 0.7 : 1) + } + + // MARK: - Sidebar stat computations + + /// Word counts for the last `days` days, summed. + private func wordsInLast(_ days: Int) -> Int { + let activity = metricsStore.getDailyActivity(days: max(days, 28)) + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + var total = 0 + for offset in 0.. 0 } + guard !active.isEmpty else { return 0 } + return active.reduce(0, +) / active.count + } + + /// Percent change of the last 28 days vs the prior 28. Nil when the + /// prior window has too little data to be meaningful. + private var monthDeltaPercent: Int? { + let activity = metricsStore.getDailyActivity(days: 56) + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + var recent = 0 + var prior = 0 + for offset in 0..<56 { + guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { continue } + let words = activity[calendar.startOfDay(for: date)] ?? 0 + if offset < 28 { recent += words } else { prior += words } + } + guard prior > 50 else { return nil } + let delta = Double(recent - prior) / Double(prior) * 100 + return Int(delta.rounded()) } // MARK: - Content switcher diff --git a/Sources/Views/Dashboard/DashboardVisualsView.swift b/Sources/Views/Dashboard/DashboardVisualsView.swift index 28056ea..4c92628 100644 --- a/Sources/Views/Dashboard/DashboardVisualsView.swift +++ b/Sources/Views/Dashboard/DashboardVisualsView.swift @@ -7,12 +7,20 @@ internal struct DashboardVisualsView: View { @AppDefault(\.waveformStyle) private var waveformStyle @AppDefault(\.visualIntensity) private var visualIntensity - @StateObject private var sampler = LivePreviewSampler() + @StateObject private var sampler: LivePreviewSampler + @StateObject private var micCapture: MicTestCapture + + init() { + let sharedSampler = LivePreviewSampler() + _sampler = StateObject(wrappedValue: sharedSampler) + _micCapture = StateObject(wrappedValue: MicTestCapture(sampler: sharedSampler)) + } var body: some View { ScrollView { VStack(alignment: .leading, spacing: DashboardTheme.Spacing.xl) { pageHeader + MicTestBanner(style: waveformStyle, sampler: sampler, capture: micCapture) waveformSection previewSection celebrationSection @@ -21,7 +29,10 @@ internal struct DashboardVisualsView: View { } .background(DashboardTheme.pageBg) .onAppear { sampler.start() } - .onDisappear { sampler.stop() } + .onDisappear { + micCapture.stop() + sampler.stop() + } } // MARK: - Page header diff --git a/Sources/Views/WelcomeView.swift b/Sources/Views/WelcomeView.swift index b6f5f0d..43006b1 100644 --- a/Sources/Views/WelcomeView.swift +++ b/Sources/Views/WelcomeView.swift @@ -289,7 +289,7 @@ private struct StyleTile: View { .padding(3) } } - .frame(height: 70) + .frame(height: 64) .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 7, style: .continuous) diff --git a/Tests/AppDelegate/AppDelegateMenuTests.swift b/Tests/AppDelegate/AppDelegateMenuTests.swift index 2ba0988..f5e4fff 100644 --- a/Tests/AppDelegate/AppDelegateMenuTests.swift +++ b/Tests/AppDelegate/AppDelegateMenuTests.swift @@ -33,7 +33,7 @@ final class AppDelegateMenuTests: XCTestCase { "Start Recording", "Transcribe a File…", "Dashboard", - "Help", + "Help / Welcome", "Quit AudioWhisper" ] diff --git a/Tests/AppDelegateExtensionTests.swift b/Tests/AppDelegateExtensionTests.swift index e52222b..09f24f1 100644 --- a/Tests/AppDelegateExtensionTests.swift +++ b/Tests/AppDelegateExtensionTests.swift @@ -80,7 +80,7 @@ final class AppDelegateExtensionTests: IsolatedXCTestCase { XCTAssertTrue(itemTitles.contains("Start Recording"), "Menu should contain Start Recording item") XCTAssertTrue(itemTitles.contains("Transcribe a File…"), "Menu should contain Transcribe a File item") XCTAssertTrue(itemTitles.contains("Dashboard"), "Menu should contain Dashboard item") - XCTAssertTrue(itemTitles.contains("Help"), "Menu should contain Help item") + XCTAssertTrue(itemTitles.contains("Help / Welcome"), "Menu should contain Help item") XCTAssertTrue(itemTitles.contains("Quit AudioWhisper"), "Menu should contain Quit item") } diff --git a/Tests/Design/LayoutMetricsTests.swift b/Tests/Design/LayoutMetricsTests.swift index 459b0b7..19f739a 100644 --- a/Tests/Design/LayoutMetricsTests.swift +++ b/Tests/Design/LayoutMetricsTests.swift @@ -99,8 +99,8 @@ final class LayoutMetricsWelcomeTests: XCTestCase { func testWelcomeWindowSize() { let size = LayoutMetrics.Welcome.windowSize - XCTAssertEqual(size.width, 600) - XCTAssertEqual(size.height, 720) + XCTAssertEqual(size.width, 580) + XCTAssertEqual(size.height, 700) } func testWelcomeWindowSizeIsReasonable() { diff --git a/Tests/Views/Dashboard/DashboardVisualsViewTests.swift b/Tests/Views/Dashboard/DashboardVisualsViewTests.swift index af91192..ad48e9c 100644 --- a/Tests/Views/Dashboard/DashboardVisualsViewTests.swift +++ b/Tests/Views/Dashboard/DashboardVisualsViewTests.swift @@ -70,4 +70,54 @@ final class DashboardVisualsViewTests: XCTestCase { XCTAssertFalse(intensity.description.isEmpty, "Visual intensity \(intensity) should have a description") } } + + // MARK: - Mic Test Banner / Sampler Mic Mode + + @MainActor + func testMicTestBannerConstructs() { + let sampler = LivePreviewSampler() + let capture = MicTestCapture(sampler: sampler) + _ = MicTestBanner(style: .classic, sampler: sampler, capture: capture) + XCTAssertEqual(capture.state, .off) + } + + @MainActor + func testSamplerMicModeTogglesAndResetsOnDisable() { + let sampler = LivePreviewSampler() + XCTAssertFalse(sampler.isMicMode) + + sampler.setMicMode(true) + XCTAssertTrue(sampler.isMicMode) + + // Real mic frames drive the published values while mic mode is on. + sampler.publishMicFrame(level: 0.8, samples: [0.5, -0.5], bands: [0.3, 0.6]) + XCTAssertEqual(sampler.audioLevel, 0.8, accuracy: 0.0001) + XCTAssertEqual(sampler.samples.count, 2) + XCTAssertEqual(sampler.bands.count, 2) + + // Disabling mic mode restores the idle defaults. + sampler.setMicMode(false) + XCTAssertFalse(sampler.isMicMode) + XCTAssertEqual(sampler.audioLevel, 0.2, accuracy: 0.0001) + XCTAssertTrue(sampler.samples.isEmpty) + XCTAssertTrue(sampler.bands.isEmpty) + } + + @MainActor + func testSamplerIgnoresMicFrameWhenMicModeOff() { + let sampler = LivePreviewSampler() + sampler.publishMicFrame(level: 0.9, samples: [1], bands: [1]) + // Mic mode is off — frame is ignored, idle default preserved. + XCTAssertEqual(sampler.audioLevel, 0.2, accuracy: 0.0001) + } + + @MainActor + func testMicCaptureStopReturnsToOffState() { + let sampler = LivePreviewSampler() + let capture = MicTestCapture(sampler: sampler) + capture.stop() + XCTAssertEqual(capture.state, .off) + XCTAssertEqual(capture.level, 0, accuracy: 0.0001) + XCTAssertFalse(capture.speaking) + } } diff --git a/Tests/Views/WelcomeViewTests.swift b/Tests/Views/WelcomeViewTests.swift index 0c5417b..21bb88a 100644 --- a/Tests/Views/WelcomeViewTests.swift +++ b/Tests/Views/WelcomeViewTests.swift @@ -23,8 +23,8 @@ final class WelcomeViewLayoutTests: XCTestCase { func testWelcomeWindowSize() { let expectedSize = LayoutMetrics.Welcome.windowSize - XCTAssertEqual(expectedSize.width, 600) - XCTAssertEqual(expectedSize.height, 720) + XCTAssertEqual(expectedSize.width, 580) + XCTAssertEqual(expectedSize.height, 700) } func testStyleGridColumns() {