Skip to content

Commit abd9886

Browse files
Birdoclaude
andcommitted
Add inline controls, custom icon, gold color scheme, and tuned voice detection
- Move font size, scroll speed, and mic sensitivity sliders into the main window for quick access - Replace generated icon with vintage microphone SVG - Update color scheme to match icon palette (gold/bronze accent) - Add loading spinner on Start button until teleprompter panel appears - Boost audio power curve (0.4 -> 0.3) for better voice pickup - Tune scroll stop responsiveness: faster hold, decay, and easing - Raise default mic sensitivity threshold to reduce false triggers - Update default welcome text to reference inline controls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 97327c1 commit abd9886

8 files changed

Lines changed: 230 additions & 138 deletions

Sources/AppState.swift

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class AppState: ObservableObject {
1010

1111
// MARK: - Teleprompter State
1212
@Published var isPrompting = false
13+
@Published var isLoading = false
1314
@Published var scrollOffset: CGFloat = 0
1415
@Published var totalContentHeight: CGFloat = 0
1516
@Published var isSpeaking = false
@@ -18,7 +19,7 @@ class AppState: ObservableObject {
1819

1920
// Speech hold: keeps scrolling during natural pauses between words
2021
private var speechHoldTimer: Timer?
21-
private let speechHoldDuration: TimeInterval = 0.6 // seconds to keep scrolling after voice drops
22+
private let speechHoldDuration: TimeInterval = 0.1 // seconds to keep scrolling after voice drops
2223

2324
// Smooth scroll velocity (eases in/out instead of instant start/stop)
2425
@Published var scrollVelocity: CGFloat = 0
@@ -70,11 +71,11 @@ class AppState: ObservableObject {
7071
init() {
7172
let defaults = UserDefaults.standard
7273
self.scriptText = defaults.string(forKey: "scriptText")
73-
?? "Welcome to Teleprompter.\n\nPaste or type your script here. When you start the teleprompter, this text will appear in a floating window near your camera.\n\nThe text scrolls automatically when it detects your voice through the microphone. Pause speaking and the scroll pauses too.\n\nYou can adjust the font size, scroll speed, colors, and microphone sensitivity in Settings.\n\nPress Command+Return to start, and Escape to stop."
74+
?? "Welcome to Teleprompter.\n\nPaste or type your script here. When you start the teleprompter, this text will appear in a floating window near your camera.\n\nThe text scrolls automatically when it detects your voice through the microphone. Pause speaking and the scroll pauses too.\n\nYou can adjust the font size, scroll speed, colors, and microphone sensitivity at the bottom of the window. Check the settings for more customisation.\n\nPress Command+Return to start, and Escape to stop."
7475
self.fontSize = CGFloat(defaults.double(forKey: "fontSize").nonZero ?? 36)
7576
self.textColorHex = defaults.string(forKey: "textColorHex") ?? "#FFFFFF"
7677
self.scrollSpeed = CGFloat(defaults.double(forKey: "scrollSpeed").nonZero ?? 55)
77-
self.micSensitivity = Float(defaults.double(forKey: "micSensitivity").nonZero ?? 0.15)
78+
self.micSensitivity = Float(defaults.double(forKey: "micSensitivity").nonZero ?? 0.28)
7879
self.windowOpacity = CGFloat(defaults.double(forKey: "windowOpacity").nonZero ?? 0.92)
7980
self.countdownDuration = defaults.object(forKey: "countdownDuration") as? Int ?? 3
8081
self.teleprompterWidth = CGFloat(defaults.double(forKey: "teleprompterWidth").nonZero ?? 480)
@@ -85,28 +86,34 @@ class AppState: ObservableObject {
8586
// MARK: - Teleprompter Control
8687

8788
func startPrompting() {
88-
guard !isPrompting, !isCountingDown else { return }
89+
guard !isPrompting, !isCountingDown, !isLoading else { return }
8990

91+
isLoading = true
9092
scrollOffset = 0
9193
isPaused = false
92-
isCountingDown = true
93-
countdownValue = countdownDuration
9494

95-
if countdownDuration == 0 {
96-
isCountingDown = false
97-
beginScrolling()
98-
return
99-
}
95+
// Brief minimum loading time, then proceed
96+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
97+
guard let self else { return }
10098

101-
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
102-
guard let self else { timer.invalidate(); return }
103-
DispatchQueue.main.async {
104-
self.countdownValue -= 1
105-
if self.countdownValue <= 0 {
106-
timer.invalidate()
107-
self.countdownTimer = nil
108-
self.isCountingDown = false
109-
self.beginScrolling()
99+
if self.countdownDuration == 0 {
100+
self.beginScrolling()
101+
return
102+
}
103+
104+
self.isCountingDown = true
105+
self.countdownValue = self.countdownDuration
106+
107+
self.countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
108+
guard let self else { timer.invalidate(); return }
109+
DispatchQueue.main.async {
110+
self.countdownValue -= 1
111+
if self.countdownValue <= 0 {
112+
timer.invalidate()
113+
self.countdownTimer = nil
114+
self.isCountingDown = false
115+
self.beginScrolling()
116+
}
110117
}
111118
}
112119
}
@@ -122,6 +129,9 @@ class AppState: ObservableObject {
122129
}
123130
panelController?.showPanel()
124131

132+
// Clear loading now that the panel is visible
133+
isLoading = false
134+
125135
// Start audio monitoring
126136
audioMonitor = AudioMonitor { [weak self] level in
127137
guard let self else { return }
@@ -156,7 +166,7 @@ class AppState: ObservableObject {
156166
DispatchQueue.main.async {
157167
let targetVelocity: CGFloat = (self.isSpeaking && !self.isPaused) ? self.scrollSpeed : 0
158168
// Ease toward target velocity for smooth start/stop
159-
let easeSpeed: CGFloat = 0.12
169+
let easeSpeed: CGFloat = 0.49
160170
self.scrollVelocity += (targetVelocity - self.scrollVelocity) * easeSpeed
161171

162172
if self.scrollVelocity > 0.1 {
@@ -169,6 +179,7 @@ class AppState: ObservableObject {
169179
func stopPrompting() {
170180
isPrompting = false
171181
isCountingDown = false
182+
isLoading = false
172183
isPaused = false
173184

174185
countdownTimer?.invalidate()

Sources/AudioLevelIndicator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ struct AudioLevelIndicator: View {
3232

3333
private var barColor: Color {
3434
if level > threshold {
35-
return Color.green
35+
return Color(hex: "#B2884F")!
3636
}
3737
return Color.white.opacity(0.3)
3838
}

Sources/AudioMonitor.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class AudioMonitor {
1111
// Smoothing state
1212
private var smoothedLevel: Float = 0
1313
private let smoothUp: Float = 0.4 // Fast attack — respond quickly to speech
14-
private let smoothDown: Float = 0.08 // Slow release — don't drop between syllables
14+
private let smoothDown: Float = 0.39 // Faster release — stop prompter quickly when voice drops
1515

1616
init(levelCallback: ((Float) -> Void)? = nil) {
1717
self.levelCallback = levelCallback
@@ -36,8 +36,9 @@ class AudioMonitor {
3636

3737
// Convert to a more perceptually useful scale:
3838
// Boost low-level signals so speech is clearly above threshold.
39-
// A simple power curve works well: level = raw^0.4 gives ~10x boost at 0.01
40-
let boosted = powf(raw, 0.4)
39+
// Power curve: lower exponent = more boost for quiet signals
40+
// 0.3 gives strong boost so normal speech is well above threshold
41+
let boosted = powf(raw, 0.3)
4142

4243
// Exponential moving average with asymmetric attack/release
4344
let alpha = boosted > self.smoothedLevel ? self.smoothUp : self.smoothDown

Sources/MainView.swift

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ struct MainView: View {
77

88
var body: some View {
99
ZStack {
10-
Color(hex: "#141416")!.ignoresSafeArea()
10+
Color(hex: "#1A1D22")!.ignoresSafeArea()
1111

1212
VStack(spacing: 0) {
1313
// Top toolbar
@@ -23,6 +23,12 @@ struct MainView: View {
2323
scriptEditor
2424
.padding(16)
2525

26+
Divider()
27+
.background(Color.white.opacity(0.06))
28+
29+
// Quick controls: font size, scroll speed, sensitivity
30+
quickControls
31+
2632
Divider()
2733
.background(Color.white.opacity(0.06))
2834

@@ -79,7 +85,7 @@ struct MainView: View {
7985
private var scriptEditor: some View {
8086
ZStack(alignment: .topLeading) {
8187
RoundedRectangle(cornerRadius: 12)
82-
.fill(Color(hex: "#1E1E22")!)
88+
.fill(Color(hex: "#22252B")!)
8389
.overlay(
8490
RoundedRectangle(cornerRadius: 12)
8591
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
@@ -102,6 +108,90 @@ struct MainView: View {
102108
}
103109
}
104110

111+
// MARK: - Quick Controls
112+
113+
private var quickControls: some View {
114+
HStack(spacing: 20) {
115+
controlSlider(
116+
icon: "textformat.size",
117+
title: "Font Size",
118+
label: "\(Int(appState.fontSize))px",
119+
value: $appState.fontSize,
120+
range: 16...80,
121+
step: 1
122+
)
123+
124+
dividerLine
125+
126+
controlSlider(
127+
icon: "arrow.up.arrow.down",
128+
title: "Speed",
129+
label: speedLabel,
130+
value: $appState.scrollSpeed,
131+
range: 10...150,
132+
step: 5
133+
)
134+
135+
dividerLine
136+
137+
controlSlider(
138+
icon: "mic.fill",
139+
title: "Sensitivity",
140+
label: sensitivityLabel,
141+
value: Binding(
142+
get: { CGFloat(appState.micSensitivity) },
143+
set: { appState.micSensitivity = Float($0) }
144+
),
145+
range: 0.05...0.5,
146+
step: 0.01
147+
)
148+
}
149+
.padding(.horizontal, 20)
150+
.padding(.vertical, 10)
151+
}
152+
153+
private func controlSlider(icon: String, title: String, label: String, value: Binding<CGFloat>, range: ClosedRange<CGFloat>, step: CGFloat) -> some View {
154+
HStack(spacing: 8) {
155+
Image(systemName: icon)
156+
.font(.system(size: 11, weight: .medium))
157+
.foregroundColor(Color(hex: "#B2884F")!.opacity(0.7))
158+
.frame(width: 14)
159+
160+
Text(title)
161+
.font(.system(size: 11, weight: .medium))
162+
.foregroundColor(.white.opacity(0.5))
163+
.fixedSize()
164+
165+
Slider(value: value, in: range, step: step)
166+
.frame(minWidth: 80)
167+
168+
Text(label)
169+
.font(.system(size: 11, weight: .medium, design: .monospaced))
170+
.foregroundColor(.white.opacity(0.4))
171+
.frame(width: 52, alignment: .trailing)
172+
}
173+
}
174+
175+
private var dividerLine: some View {
176+
Rectangle()
177+
.fill(Color.white.opacity(0.06))
178+
.frame(width: 1, height: 20)
179+
}
180+
181+
private var speedLabel: String {
182+
if appState.scrollSpeed < 30 { return "Slow" }
183+
if appState.scrollSpeed < 70 { return "Med" }
184+
if appState.scrollSpeed < 110 { return "Fast" }
185+
return "V.Fast"
186+
}
187+
188+
private var sensitivityLabel: String {
189+
if appState.micSensitivity < 0.1 { return "V.High" }
190+
if appState.micSensitivity < 0.18 { return "High" }
191+
if appState.micSensitivity < 0.3 { return "Med" }
192+
return "Low"
193+
}
194+
105195
// MARK: - Bottom Bar
106196

107197
private var bottomBar: some View {
@@ -111,10 +201,9 @@ struct MainView: View {
111201
.frame(width: 120, height: 6)
112202

113203
if appState.isPrompting {
114-
// Status
115204
HStack(spacing: 6) {
116205
Circle()
117-
.fill(appState.isPaused ? Color.yellow : Color.green)
206+
.fill(appState.isPaused ? Color(hex: "#D7B58E")! : Color(hex: "#B2884F")!)
118207
.frame(width: 8, height: 8)
119208
Text(appState.isPaused ? "Paused" : "Live")
120209
.font(.system(size: 12, weight: .medium))
@@ -150,10 +239,23 @@ struct MainView: View {
150239
.foregroundColor(.white)
151240
.padding(.horizontal, 14)
152241
.padding(.vertical, 7)
153-
.background(Color.red.opacity(0.7))
242+
.background(Color(hex: "#8B3A3A")!.opacity(0.9))
154243
.clipShape(RoundedRectangle(cornerRadius: 8))
155244
}
156245
.buttonStyle(.plain)
246+
} else if appState.isLoading {
247+
HStack(spacing: 8) {
248+
ProgressView()
249+
.controlSize(.small)
250+
.tint(.white)
251+
Text("Starting...")
252+
.font(.system(size: 14, weight: .semibold))
253+
}
254+
.foregroundColor(.white.opacity(0.7))
255+
.padding(.horizontal, 20)
256+
.padding(.vertical, 10)
257+
.background(Color.white.opacity(0.08))
258+
.clipShape(RoundedRectangle(cornerRadius: 10))
157259
} else {
158260
Button(action: { appState.startPrompting() }) {
159261
HStack(spacing: 8) {
@@ -167,7 +269,7 @@ struct MainView: View {
167269
.padding(.vertical, 10)
168270
.background(
169271
LinearGradient(
170-
colors: [Color(hex: "#6C5CE7")!, Color(hex: "#5B4ED4")!],
272+
colors: [Color(hex: "#B2884F")!, Color(hex: "#96703F")!],
171273
startPoint: .top,
172274
endPoint: .bottom
173275
)

Sources/SettingsView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ struct SettingsView: View {
107107
}
108108
}
109109
.frame(width: 500, height: 580)
110-
.background(Color(hex: "#1A1A1E")!)
110+
.background(Color(hex: "#1A1D22")!)
111111
.preferredColorScheme(.dark)
112112
}
113113

Sources/TeleprompterView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ struct TeleprompterView: View {
4747
// Reading position indicator
4848
VStack {
4949
Rectangle()
50-
.fill(Color(hex: "#6C5CE7")!.opacity(0.4))
50+
.fill(Color(hex: "#B2884F")!.opacity(0.5))
5151
.frame(height: 1)
5252
.padding(.horizontal, 20)
5353
.offset(y: viewHeight * 0.3)
@@ -84,7 +84,7 @@ struct TeleprompterView: View {
8484
HStack {
8585
Spacer()
8686
Circle()
87-
.fill(appState.isSpeaking ? Color.green : Color.white.opacity(0.2))
87+
.fill(appState.isSpeaking ? Color(hex: "#B2884F")! : Color.white.opacity(0.2))
8888
.frame(width: 8, height: 8)
8989
.animation(.easeInOut(duration: 0.15), value: appState.isSpeaking)
9090
.padding(12)

0 commit comments

Comments
 (0)