| 1 | import SwiftUI |
| 2 | import CoreAudio |
| 3 | |
| 4 | struct 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. |
| 74 | private 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. |
| 98 | private 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 | |
| 134 | private 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 | } |