Skip to content

Commit 634ae69

Browse files
javiertoledoclaude
andcommitted
Fix stream: use library streaming config, show volatile + confirmed updates
The 3s chunk config was too short for Parakeet — model needs ~10s context. Reverted to the library's .streaming preset (11s chunks, 1s hypothesis). Now shows two types of updates: - Volatile (hypothesis): shown as ephemeral line on stderr with \r overwrite Gives immediate ~1-2s feedback while speaking - Confirmed: printed as permanent line to stdout Stable, final text after sufficient context Also fixes: - Stream getting stuck on longer utterances (was breaking model state) - Text format shows live preview on stderr, final on stdout - JSONL emits both volatile and confirmed (with "confirmed" field) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 289299e commit 634ae69

1 file changed

Lines changed: 54 additions & 45 deletions

File tree

Sources/scribe/Commands/Stream.swift

Lines changed: 54 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Foundation
55

66
struct Stream: AsyncParsableCommand {
77
static let configuration = CommandConfiguration(
8-
abstract: "Stream live audio transcription from microphone or system audio."
8+
abstract: "Stream live audio transcription from microphone."
99
)
1010

1111
@Option(name: .long, help: "Output format: text or jsonl.")
@@ -20,16 +20,8 @@ struct Stream: AsyncParsableCommand {
2020
func run() async throws {
2121
if verbose { log("Initializing streaming ASR (Parakeet)...") }
2222

23-
// Low-latency config: 3s chunks for fast output, 1s hypothesis for immediate feedback
24-
let config = SlidingWindowAsrConfig(
25-
chunkSeconds: 3.0,
26-
hypothesisChunkSeconds: 1.0,
27-
leftContextSeconds: 3.0,
28-
rightContextSeconds: 0.5,
29-
minContextForConfirmation: 3.0,
30-
confirmationThreshold: 0.5
31-
)
32-
let streamManager = SlidingWindowAsrManager(config: config)
23+
// Use the library's streaming config (11s chunks + 1s hypothesis updates)
24+
let streamManager = SlidingWindowAsrManager(config: .streaming)
3325
try await streamManager.start(source: .microphone)
3426

3527
// Set up microphone capture
@@ -41,24 +33,22 @@ struct Stream: AsyncParsableCommand {
4133
log("Microphone: \(inputFormat.sampleRate)Hz, \(inputFormat.channelCount) ch")
4234
}
4335

44-
// Track state for output
4536
let startTime = Date()
4637
var outputFile: FileHandle? = nil
47-
var utteranceCount = 0
48-
var lastEmittedText = ""
38+
var lastConfirmedText = ""
39+
var lastVolatileText = ""
4940

5041
if let outputPath = output {
5142
FileManager.default.createFile(atPath: outputPath, contents: nil)
5243
outputFile = FileHandle(forWritingAtPath: outputPath)
5344
}
5445

55-
// Set up signal handler for clean Ctrl+C
5646
signal(SIGINT) { _ in
5747
FileHandle.standardError.write(Data("\n".utf8))
5848
Darwin.exit(0)
5949
}
6050

61-
// Install tap on microphone — feed buffers to ASR
51+
// Install tap on microphone
6252
inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { buffer, _ in
6353
nonisolated(unsafe) let buf = buffer
6454
streamManager.streamAudio(buf)
@@ -73,37 +63,39 @@ struct Stream: AsyncParsableCommand {
7363
let text = update.text.trimmingCharacters(in: .whitespaces)
7464
guard !text.isEmpty else { continue }
7565

76-
// Skip if this is the same text we already emitted (dedup)
77-
guard text != lastEmittedText else { continue }
78-
lastEmittedText = text
79-
80-
utteranceCount += 1
8166
let elapsed = Date().timeIntervalSince(startTime)
8267

83-
let line: String
84-
switch format {
85-
case .text:
86-
let ts = formatTimestamp(elapsed)
87-
line = "[\(ts)] \(text)"
88-
case .jsonl:
89-
let jsonObj: [String: Any] = [
90-
"time": round(elapsed * 10) / 10,
91-
"text": text,
92-
"confirmed": update.isConfirmed,
93-
]
94-
if let data = try? JSONSerialization.data(withJSONObject: jsonObj, options: [.sortedKeys]),
95-
let jsonStr = String(data: data, encoding: .utf8) {
96-
line = jsonStr
97-
} else {
98-
continue
99-
}
100-
}
68+
if update.isConfirmed {
69+
// Confirmed: stable, final text — print as a permanent line
70+
guard text != lastConfirmedText else { continue }
71+
lastConfirmedText = text
72+
lastVolatileText = "" // reset volatile tracking
10173

102-
print(line)
103-
fflush(stdout)
74+
let line = formatLine(text: text, elapsed: elapsed, confirmed: true)
75+
print(line)
76+
fflush(stdout)
10477

105-
if let file = outputFile {
106-
file.write(Data((line + "\n").utf8))
78+
if let file = outputFile {
79+
file.write(Data((line + "\n").utf8))
80+
}
81+
} else {
82+
// Volatile: hypothesis, may change — show as ephemeral line
83+
guard text != lastVolatileText else { continue }
84+
lastVolatileText = text
85+
86+
switch format {
87+
case .text:
88+
// Overwrite current line with \r for live feel
89+
let ts = formatTimestamp(elapsed)
90+
let preview = String(text.suffix(80))
91+
let line = "\r[\(ts)] \(preview)"
92+
FileHandle.standardError.write(Data(line.utf8))
93+
case .jsonl:
94+
// In JSONL mode, emit volatile updates too (marked as unconfirmed)
95+
let line = formatLine(text: text, elapsed: elapsed, confirmed: false)
96+
print(line)
97+
fflush(stdout)
98+
}
10799
}
108100
}
109101

@@ -113,11 +105,9 @@ struct Stream: AsyncParsableCommand {
113105
_ = try await streamManager.finish()
114106

115107
let totalElapsed = Date().timeIntervalSince(startTime)
116-
117108
FileHandle.standardError.write(Data("\n--- Stream ended ---\n".utf8))
118109
FileHandle.standardError.write(Data(String(format: "Duration: %dm %ds\n",
119110
Int(totalElapsed) / 60, Int(totalElapsed) % 60).utf8))
120-
FileHandle.standardError.write(Data("Utterances: \(utteranceCount)\n".utf8))
121111

122112
if let outputPath = output {
123113
FileHandle.standardError.write(Data("Saved to: \(outputPath)\n".utf8))
@@ -126,6 +116,25 @@ struct Stream: AsyncParsableCommand {
126116
outputFile?.closeFile()
127117
}
128118

119+
private func formatLine(text: String, elapsed: Double, confirmed: Bool) -> String {
120+
switch format {
121+
case .text:
122+
let ts = formatTimestamp(elapsed)
123+
return "[\(ts)] \(text)"
124+
case .jsonl:
125+
let jsonObj: [String: Any] = [
126+
"time": round(elapsed * 10) / 10,
127+
"text": text,
128+
"confirmed": confirmed,
129+
]
130+
if let data = try? JSONSerialization.data(withJSONObject: jsonObj, options: [.sortedKeys]),
131+
let jsonStr = String(data: data, encoding: .utf8) {
132+
return jsonStr
133+
}
134+
return "{\"text\":\"\(text)\"}"
135+
}
136+
}
137+
129138
private func log(_ message: String) {
130139
FileHandle.standardError.write(Data("[scribe] \(message)\n".utf8))
131140
}

0 commit comments

Comments
 (0)