Skip to content

Commit d13970a

Browse files
committed
Read Aloud: accurate widget status + readable tables
- Widget now reflects the real phase: "Thinking…" while the on-device AI rewrites (new .generatingSpeech state), then "Preparing…" during synthesis, then the live bars while speaking — instead of one generic "Preparing…". - Tables read like sentences: rows ("│ a │ b │" or "| a | b |") become "a, b." with pauses; border/separator rows collapse; stray underscores stripped. AI prompt told to read tables/lists row by row, never borders.
1 parent c81c4dc commit d13970a

9 files changed

Lines changed: 65 additions & 11 deletions

File tree

native-macos/Zerm/HotkeyManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ class HotkeyManager: ObservableObject {
7171

7272
// MARK: - Helper Properties
7373
private var canProcessHotkeyAction: Bool {
74-
engine.recordingState != .transcribing && engine.recordingState != .enhancing && engine.recordingState != .busy && engine.recordingState != .speaking && engine.recordingState != .preparingSpeech
74+
engine.recordingState != .transcribing && engine.recordingState != .enhancing && engine.recordingState != .busy && engine.recordingState != .speaking && engine.recordingState != .preparingSpeech && engine.recordingState != .generatingSpeech
7575
}
7676

7777
// NSEvent monitoring for modifier keys

native-macos/Zerm/TextToSpeech/TTSController.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,10 @@ final class TTSController: ObservableObject {
152152
let cleaned = base.isEmpty ? raw : base
153153

154154
if TTSSettings.naturalReadingAI, naturalizer.isModelInstalled {
155-
if let rewritten = await naturalizer.naturalize(cleaned, isCancelled: { Task.isCancelled }) {
156-
return rewritten
157-
}
155+
recorderUIManager?.beginGenerating() // widget shows "Thinking…"
156+
let rewritten = await naturalizer.naturalize(cleaned, isCancelled: { Task.isCancelled })
157+
recorderUIManager?.endGenerating() // back to "Preparing…" for synthesis
158+
if let rewritten { return rewritten }
158159
}
159160
return cleaned
160161
}

native-macos/Zerm/TextToSpeech/TTSNaturalizer.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ final class TTSNaturalizer {
6464
(e.g. "Error: ENOENT""there was a file-not-found error"). Never read an emoji or symbol by \
6565
its name — never say things like "white heavy check mark" or "heavy right arrow".
6666
- Remove markup and formatting. Expand abbreviations; read numbers and currency naturally.
67+
- If the text is a table or list, read it as natural sentences (row by row, mentioning the \
68+
column meaning where helpful). Never read separators or column borders.
6769
- Keep it about the same length as the original. Reply with ONLY the spoken text, nothing else.
6870
6971
Example —

native-macos/Zerm/TextToSpeech/TTSTextNormalizer.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ enum TTSTextNormalizer {
4444
static func normalize(_ raw: String) -> String {
4545
var text = raw
4646

47+
// 0. Tables: turn each row ("│ a │ b │" or "| a | b |") into "a, b." so cells read with
48+
// pauses and rows are separate sentences, instead of a stripped-glyph run-on.
49+
text = flattenTables(text)
50+
4751
// 1. Markdown line markers (headings, bullets, quotes, ordered lists).
4852
text = regexReplace(#"(?m)^\s*#{1,6}\s+"#, in: text, with: "")
4953
text = regexReplace(#"(?m)^\s*[-*+]\s+"#, in: text, with: "")
@@ -78,6 +82,7 @@ enum TTSTextNormalizer {
7882

7983
// 7. snake_case / kebab-ish underscores and camelCase → spaced words.
8084
text = regexReplace("([A-Za-z0-9])_([A-Za-z0-9])", in: text, with: "$1 $2")
85+
text = regexReplace("_+", in: text, with: " ") // strip any leftover underscores
8186
text = regexReplace("([a-z0-9])([A-Z])", in: text, with: "$1 $2")
8287

8388
// 8. Emphasis markers and stray symbols that shouldn't be spoken.
@@ -111,6 +116,28 @@ enum TTSTextNormalizer {
111116
return text.trimmingCharacters(in: .whitespacesAndNewlines)
112117
}
113118

119+
// MARK: - Tables
120+
121+
/// Converts table rows (Unicode box `│` or markdown `|`) into spoken "cell, cell." sentences.
122+
/// Border/separator rows (only box-drawing or dashes) collapse to nothing.
123+
private static func flattenTables(_ text: String) -> String {
124+
guard text.contains("") || text.contains("|") else { return text }
125+
let lines = text.components(separatedBy: "\n")
126+
let converted = lines.map { line -> String in
127+
guard line.contains("") || line.contains("|") else { return line }
128+
let cells = line
129+
.split(whereSeparator: { $0 == "" || $0 == "|" })
130+
.map { $0.trimmingCharacters(in: .whitespaces) }
131+
.filter { cell in
132+
guard !cell.isEmpty else { return false }
133+
// Drop markdown separator cells like "---" or ":---:".
134+
return !cell.allSatisfy { "-:= ".contains($0) }
135+
}
136+
return cells.isEmpty ? "" : cells.joined(separator: ", ") + "."
137+
}
138+
return converted.joined(separator: "\n")
139+
}
140+
114141
// MARK: - Symbols & emoji
115142

116143
/// Status-bearing symbols a person would actually voice, mapped to short words.

native-macos/Zerm/Transcription/Engine/RecorderUIManager.swift

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ class RecorderUIManager: ObservableObject {
5757
/// Stops Read Aloud playback. Set by the app to TTSController.stop().
5858
var onCancelSpeaking: (() -> Void)?
5959

60-
/// True while Read Aloud owns the widget (synthesizing or playing).
60+
/// True while Read Aloud owns the widget (thinking, synthesizing, or playing).
6161
var isReadAloudActive: Bool {
62-
engine?.recordingState == .speaking || engine?.recordingState == .preparingSpeech
62+
let s = engine?.recordingState
63+
return s == .speaking || s == .preparingSpeech || s == .generatingSpeech
6364
}
6465

6566
/// Whether Read Aloud may start — only when nothing else is using the recorder.
@@ -75,10 +76,26 @@ class RecorderUIManager: ObservableObject {
7576
isMiniRecorderVisible = true
7677
}
7778

78-
/// Switches the widget from "Preparing…" to the live audio bars once playback starts.
79-
func markSpeechPlaying() {
79+
/// Shows the "Thinking…" state while the on-device AI rewrites the text.
80+
func beginGenerating() {
8081
guard let engine = engine else { return }
8182
if engine.recordingState == .preparingSpeech {
83+
engine.recordingState = .generatingSpeech
84+
}
85+
}
86+
87+
/// Returns to "Preparing…" once the AI rewrite finishes and synthesis begins.
88+
func endGenerating() {
89+
guard let engine = engine else { return }
90+
if engine.recordingState == .generatingSpeech {
91+
engine.recordingState = .preparingSpeech
92+
}
93+
}
94+
95+
/// Switches the widget from the loading states to the live audio bars once playback starts.
96+
func markSpeechPlaying() {
97+
guard let engine = engine else { return }
98+
if engine.recordingState == .preparingSpeech || engine.recordingState == .generatingSpeech {
8299
engine.recordingState = .speaking
83100
}
84101
}

native-macos/Zerm/Transcription/Engine/RecordingState.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ enum RecordingState: Equatable {
66
case recording
77
case transcribing
88
case enhancing
9-
case preparingSpeech // Read Aloud is synthesizing — widget shows a loading indicator
9+
case generatingSpeech // Read Aloud is running the on-device AI rewrite — widget shows "Thinking…"
10+
case preparingSpeech // Read Aloud is synthesizing audio — widget shows "Preparing…"
1011
case speaking // Read Aloud audio is playing — animated bars
1112
case busy
1213
}

native-macos/Zerm/Views/Recorder/AudioVisualizerView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ struct ProcessingStatusDisplay: View {
7171
case transcribing
7272
case enhancing
7373
case preparing
74+
case generating
7475
}
7576

7677
let mode: Mode
@@ -81,6 +82,7 @@ struct ProcessingStatusDisplay: View {
8182
case .transcribing: return "Transcribing"
8283
case .enhancing: return "Enhancing"
8384
case .preparing: return "Preparing…"
85+
case .generating: return "Thinking…"
8486
}
8587
}
8688

@@ -89,6 +91,7 @@ struct ProcessingStatusDisplay: View {
8991
case .transcribing: return 0.18
9092
case .enhancing: return 0.22
9193
case .preparing: return 0.14
94+
case .generating: return 0.16
9295
}
9396
}
9497

native-macos/Zerm/Views/Recorder/NotchRecorderView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ struct NotchRecorderView<S: RecorderStateProvider & ObservableObject>: View {
2222
case .recording:
2323
let shouldShowLive = showLiveTextPreview && !stateProvider.partialTranscript.isEmpty
2424
return shouldShowLive ? .liveText : .active
25-
case .transcribing, .enhancing, .speaking, .preparingSpeech:
25+
case .transcribing, .enhancing, .speaking, .preparingSpeech, .generatingSpeech:
2626
return .active
2727
default:
2828
return .collapsed

native-macos/Zerm/Views/Recorder/RecorderComponents.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,10 @@ struct RecorderStatusDisplay: View {
314314

315315
var body: some View {
316316
Group {
317-
if currentState == .preparingSpeech {
317+
if currentState == .generatingSpeech {
318+
// Read Aloud is running the on-device AI rewrite — show "Thinking…".
319+
ProcessingStatusDisplay(mode: .generating, color: .white).transition(.opacity)
320+
} else if currentState == .preparingSpeech {
318321
// Read Aloud is synthesizing — show a loading indicator until audio starts.
319322
ProcessingStatusDisplay(mode: .preparing, color: .white).transition(.opacity)
320323
} else if currentState == .speaking {

0 commit comments

Comments
 (0)