1import SwiftUI
2import CoreAudio
3
4struct TopBar: View {
5 @ObservedObject var engine: AudioEngine
6 @ObservedObject var settings: Settings
7
8 var body: some View {
9 HStack(spacing: 14) {
10 // Microphone
11 Dropdown(icon: "mic.fill") {
12 Picker("", selection: micBinding) {
13 if engine.devices.isEmpty {
14 Text("No inputs").tag(AudioDeviceID?.none)
15 }
16 ForEach(engine.devices) { d in
17 Text(d.name).tag(AudioDeviceID?.some(d.id))
18 }
19 }
20 .labelsHidden()
21 }
22
23 // Transpose
24 Dropdown(icon: "arrow.up.arrow.down") {
25 Picker("", selection: $settings.transpose) {
26 ForEach(Settings.transposeChoices, id: \.self) { n in
27 Text(settings.transposeLabel(n)).tag(n)
28 }
29 }
30 .labelsHidden()
31 }
32
33 // Note labels
34 Dropdown(icon: "textformat") {
35 Picker("", selection: $settings.naming) {
36 ForEach(NoteNaming.allCases) { n in
37 Text(n.rawValue).tag(n)
38 }
39 }
40 .labelsHidden()
41 }
42
43 // Range
44 Dropdown(icon: "arrow.up.and.down") {
45 Picker("", selection: $settings.range) {
46 ForEach(PitchRange.presets) { r in
47 Text(r.name).tag(r)
48 }
49 }
50 .labelsHidden()
51 }
52
53 Spacer(minLength: 8)
54
55 KeyPill(engine: engine, settings: settings)
56 StatusPill(engine: engine)
57 }
58 .padding(.horizontal, 16)
59 .padding(.vertical, 10)
60 .background(.bar)
61 .overlay(Rectangle().frame(height: 1).foregroundColor(Color(nsColor: .separatorColor)), alignment: .bottom)
62 }
63
64 private var micBinding: Binding<AudioDeviceID?> {
65 Binding(
66 get: { engine.selectedDeviceID },
67 set: { if let id = $0 { engine.selectDevice(id) } }
68 )
69 }
70}
71
72/// A pill-shaped container that gives each Picker a leading icon and a
73/// consistent dark style.
74private struct Dropdown<Content: View>: View {
75 let icon: String
76 @ViewBuilder var content: Content
77
78 var body: some View {
79 HStack(spacing: 6) {
80 Image(systemName: icon)
81 .font(.system(size: 11, weight: .semibold))
82 .foregroundColor(.secondary)
83 content
84 .tint(.primary)
85 }
86 .padding(.horizontal, 10)
87 .padding(.vertical, 5)
88 .background(
89 RoundedRectangle(cornerRadius: 8)
90 .fill(Color(nsColor: .controlBackgroundColor))
91 .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(nsColor: .separatorColor), lineWidth: 1))
92 )
93 }
94}
95
96/// Live key estimate (Krumhansl–Schmuckler) shown in the top bar. Dims when the
97/// fit is weak so a shaky guess reads as tentative.
98private struct KeyPill: View {
99 @ObservedObject var engine: AudioEngine
100 @ObservedObject var settings: Settings
101
102 var body: some View {
103 HStack(spacing: 6) {
104 Image(systemName: "music.note")
105 .font(.system(size: 11, weight: .semibold))
106 .foregroundColor(.secondary)
107 Text(text)
108 .font(.system(size: 11, weight: .medium))
109 .foregroundColor(.secondary)
110 .lineLimit(1)
111 }
112 .opacity(opacity)
113 .padding(.horizontal, 10)
114 .padding(.vertical, 5)
115 .background(
116 RoundedRectangle(cornerRadius: 8)
117 .fill(Color(nsColor: .controlBackgroundColor))
118 .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(nsColor: .separatorColor), lineWidth: 1))
119 )
120 .help("Estimated key (Krumhansl–Schmuckler), from the notes you've sung")
121 }
122
123 private var text: String {
124 guard let k = engine.keyEstimate else { return "Key —" }
125 return "Key " + Music.keyName(k, naming: settings.naming, transpose: settings.transpose)
126 }
127
128 private var opacity: Double {
129 guard let k = engine.keyEstimate else { return 0.55 }
130 return 0.6 + 0.4 * min(1, k.confidence / 0.1)
131 }
132}
133
134private struct StatusPill: View {
135 @ObservedObject var engine: AudioEngine
136
137 var body: some View {
138 HStack(spacing: 7) {
139 Circle()
140 .fill(color)
141 .frame(width: 8, height: 8)
142 Text(text)
143 .font(.system(size: 11, weight: .medium))
144 .foregroundColor(.secondary)
145 .lineLimit(1)
146 }
147 .padding(.horizontal, 10)
148 .padding(.vertical, 5)
149 .help(engine.statusMessage ?? text)
150 }
151
152 private var color: Color {
153 if engine.permission == .denied { return .red }
154 return engine.isRunning ? Color(red: 0.3, green: 0.75, blue: 0.5) : .secondary
155 }
156
157 private var text: String {
158 if engine.permission == .denied { return "Mic denied" }
159 return engine.isRunning ? "Listening" : "Idle"
160 }
161}