scribit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scribit/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ __version__ = "0.1.0"
2
+ from .main import ScribitApp, main
3
+
4
+ __all__ = ["ScribitApp", "main"]
scribit/main.py ADDED
@@ -0,0 +1,854 @@
1
+ import logging
2
+ import os
3
+ import time
4
+ import json
5
+ import math
6
+ import struct
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Optional, Dict, Any, List
10
+ import platformdirs
11
+ from dotenv import load_dotenv, set_key
12
+ import pyaudio
13
+ from textual import on, work
14
+ from textual.reactive import reactive
15
+ from textual.app import App, ComposeResult
16
+ from textual.widgets import RichLog, Header, Footer, Static, Label, Input, Button, Switch, Checkbox, Select
17
+ from textual.containers import Container, Vertical, Horizontal, Grid
18
+ from textual.binding import Binding
19
+ from textual.screen import ModalScreen
20
+ from rich.text import Text
21
+ from rich.panel import Panel
22
+
23
+ import assemblyai as aai
24
+ from assemblyai.streaming.v3 import (
25
+ BeginEvent,
26
+ StreamingClient,
27
+ StreamingClientOptions,
28
+ StreamingError,
29
+ StreamingEvents,
30
+ TerminationEvent,
31
+ TurnEvent,
32
+ StreamingParameters,
33
+ )
34
+
35
+ # Constants & Paths
36
+ APP_NAME = "scribit"
37
+ CONFIG_DIR = Path(platformdirs.user_config_dir(APP_NAME))
38
+ LOG_DIR = Path(platformdirs.user_log_dir(APP_NAME))
39
+ SETTINGS_FILE = CONFIG_DIR / "settings.json"
40
+
41
+ # Ensure directories exist
42
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
43
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
44
+
45
+ def get_audio_devices() -> List[tuple]:
46
+ """Get a list of available input devices for the Select widget."""
47
+ audio = pyaudio.PyAudio()
48
+ devices = []
49
+
50
+ def clean_name(name: str | bytes) -> str:
51
+ """Clean encoding issues common with PyAudio on Windows."""
52
+ if isinstance(name, bytes):
53
+ try:
54
+ return name.decode('utf-8')
55
+ except UnicodeDecodeError:
56
+ return name.decode('cp1252', errors='replace')
57
+
58
+ # If already a string but has UTF-8 corruption (e.g. ó for ó)
59
+ try:
60
+ return name.encode('cp1252').decode('utf-8')
61
+ except (UnicodeEncodeError, UnicodeDecodeError):
62
+ return name
63
+
64
+ try:
65
+ count = audio.get_device_count()
66
+ for i in range(count):
67
+ info = audio.get_device_info_by_index(i)
68
+ if info.get('maxInputChannels') > 0:
69
+ name = clean_name(info.get('name', 'Unknown Device'))
70
+ devices.append((name, i))
71
+ except Exception:
72
+ pass
73
+ finally:
74
+ audio.terminate()
75
+ return devices
76
+
77
+ def load_settings() -> Dict[str, Any]:
78
+ """Load settings from JSON file with defaults."""
79
+ defaults = {
80
+ "api_key": os.getenv("ASSEMBLYAI_API_KEY", ""),
81
+ "device_index": 2,
82
+ "save_logs": False
83
+ }
84
+ if os.path.exists(SETTINGS_FILE):
85
+ try:
86
+ with open(SETTINGS_FILE, "r") as f:
87
+ settings = json.load(f)
88
+ return {**defaults, **settings}
89
+ except Exception:
90
+ pass
91
+ return defaults
92
+
93
+ def save_settings(settings: Dict[str, Any]):
94
+ """Save settings to JSON file."""
95
+ with open(SETTINGS_FILE, "w") as f:
96
+ json.dump(settings, f, indent=4)
97
+ # Also update .env for compatibility
98
+ if settings.get("api_key"):
99
+ set_key(".env", "ASSEMBLYAI_API_KEY", settings["api_key"])
100
+
101
+ class SystemAudioStream:
102
+ def __init__(self, device_index, sample_rate=16000, chunk_size=1024):
103
+ self.device_index = device_index
104
+ self.sample_rate = sample_rate
105
+ self.chunk_size = chunk_size
106
+ self.audio = pyaudio.PyAudio()
107
+ self.stream = None
108
+
109
+ def __enter__(self):
110
+ self.stream = self.audio.open(
111
+ format=pyaudio.paInt16,
112
+ channels=1,
113
+ rate=self.sample_rate,
114
+ input=True,
115
+ input_device_index=self.device_index,
116
+ frames_per_buffer=self.chunk_size
117
+ )
118
+ return self
119
+
120
+ def __exit__(self, exc_type, exc_val, exc_tb):
121
+ if self.stream:
122
+ try:
123
+ self.stream.stop_stream()
124
+ self.stream.close()
125
+ except:
126
+ pass
127
+ self.audio.terminate()
128
+
129
+ def __iter__(self):
130
+ return self
131
+
132
+ def __next__(self):
133
+ if self.stream:
134
+ try:
135
+ data = self.stream.read(self.chunk_size, exception_on_overflow=False)
136
+ return data
137
+ except Exception:
138
+ raise StopIteration
139
+ else:
140
+ raise StopIteration
141
+
142
+ class SettingsScreen(ModalScreen):
143
+ """Modal screen for configuring application settings."""
144
+ def __init__(self, settings: Dict[str, Any]):
145
+ super().__init__()
146
+ self.settings = settings
147
+ self.devices = get_audio_devices()
148
+
149
+ def compose(self) -> ComposeResult:
150
+ with Grid(id="settings-form"):
151
+ yield Label("SETTINGS", id="settings-title")
152
+
153
+ yield Label("AssemblyAI API Key")
154
+ yield Input(value=self.settings.get("api_key", ""), placeholder="Paste API Key here", id="input-api-key")
155
+
156
+ yield Label("Input Audio Device")
157
+ yield Select(
158
+ options=self.devices,
159
+ value=self.settings.get("device_index", 2),
160
+ id="select-device"
161
+ )
162
+
163
+ yield Label("Save Transcript Logs")
164
+ yield Switch(value=self.settings.get("save_logs", False), id="switch-save-logs")
165
+
166
+ with Horizontal(id="settings-buttons"):
167
+ yield Button("SAVE", variant="primary", id="btn-save")
168
+ yield Button("CANCEL", variant="error", id="btn-cancel")
169
+
170
+ def on_button_pressed(self, event: Button.Pressed) -> None:
171
+ if event.button.id == "btn-save":
172
+ new_settings = {
173
+ "api_key": self.query_one("#input-api-key", Input).value.strip(),
174
+ "device_index": self.query_one("#select-device", Select).value,
175
+ "save_logs": self.query_one("#switch-save-logs", Switch).value
176
+ }
177
+ save_settings(new_settings)
178
+ self.dismiss(new_settings)
179
+ else:
180
+ self.dismiss(None)
181
+
182
+ class ExportScreen(ModalScreen):
183
+ """Modal screen for exporting the transcription session."""
184
+ def __init__(self, stats: Dict[str, Any]):
185
+ super().__init__()
186
+ self.stats = stats
187
+ # Default path to Downloads
188
+ default_path = str(Path.home() / "Downloads" / f"scribit_session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md")
189
+ self.default_path = default_path
190
+
191
+ def compose(self) -> ComposeResult:
192
+ with Vertical(id="export-form"):
193
+ yield Label("EXPORT SESSION", id="export-title")
194
+ yield Label("Export Path (Markdown)")
195
+ yield Input(value=self.default_path, id="input-export-path")
196
+ with Horizontal(id="export-buttons"):
197
+ yield Button("EXPORT", variant="primary", id="btn-do-export")
198
+ yield Button("CANCEL", variant="error", id="btn-export-cancel")
199
+
200
+ def on_button_pressed(self, event: Button.Pressed) -> None:
201
+ if event.button.id == "btn-do-export":
202
+ path = self.query_one("#input-export-path", Input).value.strip()
203
+ self.dismiss(path)
204
+ else:
205
+ self.dismiss(None)
206
+
207
+ def on_key(self, event) -> None:
208
+ if event.key == "d" or event.key == "escape":
209
+ self.dismiss(None)
210
+
211
+ class Sidebar(Vertical):
212
+ """Sidebar containing session info and status."""
213
+ def compose(self) -> ComposeResult:
214
+ with Vertical(id="sidebar-content"):
215
+ yield Label("SCRIBIT", id="logo-text")
216
+
217
+ status_label = Static("IDLE", id="status-label", classes="status-waiting")
218
+ status_label.border_title = "SYSTEM STATUS"
219
+ yield status_label
220
+
221
+ with Vertical(id="stats-container") as stats:
222
+ stats.border_title = "SESSION METRICS"
223
+ with Horizontal(classes="stat-row"):
224
+ yield Label("Duration: ")
225
+ yield Label("0s", id="stat-duration", classes="stat-value")
226
+ with Horizontal(classes="stat-row"):
227
+ yield Label("Turns: ")
228
+ yield Label("0", id="stat-turns", classes="stat-value")
229
+ with Horizontal(classes="stat-row"):
230
+ yield Label("Total Words: ")
231
+ yield Label("0", id="stat-words", classes="stat-value")
232
+ with Horizontal(classes="stat-row"):
233
+ yield Label("Total Chars: ")
234
+ yield Label("0", id="stat-chars", classes="stat-value")
235
+ with Horizontal(classes="stat-row"):
236
+ yield Label("Accuracy: ")
237
+ yield Label("100%", id="stat-accuracy", classes="stat-value")
238
+
239
+ with Vertical(id="config-container") as config:
240
+ config.border_title = "SYSTEM CONFIG"
241
+ with Horizontal(classes="stat-row"):
242
+ yield Label("Audio: ")
243
+ yield Label("Detecting...", id="device-info", classes="stat-value")
244
+ with Horizontal(classes="stat-row"):
245
+ yield Label("Logging: ")
246
+ yield Label("ON", id="logging-status", classes="stat-value log-on")
247
+
248
+ with Vertical(id="perf-container") as perf:
249
+ perf.border_title = "PERFORMANCE"
250
+ with Horizontal(classes="stat-row"):
251
+ yield Label("Latency: ")
252
+ yield Label("0ms", id="stat-latency", classes="stat-value")
253
+ with Horizontal(classes="stat-row"):
254
+ yield Label("Audio Level: ")
255
+ yield Label("[..........]", id="vu-meter", classes="stat-value")
256
+
257
+
258
+ class TranscriptionFlow(Vertical):
259
+ """Main area for transcription output."""
260
+ def compose(self) -> ComposeResult:
261
+ final_log = RichLog(id="final-log", wrap=True, highlight=True, markup=True)
262
+ final_log.border_title = "TRANSCRIPTION"
263
+ yield final_log
264
+
265
+ partial_buffer = Static("READY - PRESS SPACE TO START", id="partial-buffer")
266
+ partial_buffer.border_title = "PENDING BUFFER"
267
+ yield partial_buffer
268
+
269
+ class ScribitApp(App):
270
+ TITLE = "SCRIBIT"
271
+ SUB_TITLE = "Real-time Transcription TUI"
272
+
273
+ volume = reactive(0)
274
+ latency = reactive(0)
275
+ last_chunk_time = 0
276
+
277
+ BINDINGS = [
278
+ Binding("q", "quit", "Quit", show=True),
279
+ Binding("c", "clear_log", "Clear", show=True),
280
+ Binding("space", "toggle_recording", "Record", show=True),
281
+ Binding("s", "open_settings", "Settings", show=True),
282
+ Binding("d", "export_session", "Download", show=True),
283
+ ]
284
+
285
+ CSS = """
286
+ $background: #000000;
287
+ $surface: #0a0a0a;
288
+ $panel: #111111;
289
+ $accent: #8b5cf6;
290
+ $secondary: #c084fc;
291
+ $text: #f3f4f6;
292
+ $subtext: #9ca3af;
293
+ $green: #10b981;
294
+ $red: #ef4444;
295
+ $yellow: #f59e0b;
296
+
297
+ Screen {
298
+ background: $background;
299
+ color: $text;
300
+ }
301
+
302
+ Header {
303
+ display: none;
304
+ }
305
+
306
+ #logo-text {
307
+ color: $accent;
308
+ text-style: bold;
309
+ background: transparent;
310
+ width: 100%;
311
+ text-align: center;
312
+ padding: 1 2;
313
+ margin-bottom: 2;
314
+ border: round $accent;
315
+ }
316
+
317
+ Footer {
318
+ background: $panel;
319
+ color: $text;
320
+ dock: bottom;
321
+ height: 1;
322
+ }
323
+
324
+ Footer > .footer--key {
325
+ background: $accent;
326
+ color: $background;
327
+ text-style: bold;
328
+ }
329
+
330
+ #main-container {
331
+ layout: horizontal;
332
+ height: 1fr;
333
+ }
334
+
335
+ Sidebar {
336
+ width: 35;
337
+ background: $surface;
338
+ border-right: round $panel;
339
+ padding: 1 2;
340
+ }
341
+
342
+ .sidebar-title, .area-title {
343
+ display: none;
344
+ }
345
+
346
+ #status-label, #stats-container, #config-container, #perf-container, #final-log, #partial-buffer {
347
+ border-title-align: left;
348
+ border-title-color: $subtext;
349
+ border-title-style: bold;
350
+ }
351
+
352
+ #status-label {
353
+ background: transparent;
354
+ color: $text;
355
+ padding: 0 1;
356
+ margin-top: 1;
357
+ margin-bottom: 1;
358
+ text-align: center;
359
+ border: round $accent;
360
+ height: 3;
361
+ content-align: center middle;
362
+ }
363
+
364
+ .status-active {
365
+ border: round $green !important;
366
+ color: $green !important;
367
+ text-style: bold;
368
+ }
369
+
370
+ .status-error {
371
+ border: round $red !important;
372
+ color: $red !important;
373
+ text-style: bold;
374
+ }
375
+
376
+ .status-waiting {
377
+ border: round $yellow !important;
378
+ color: $yellow !important;
379
+ text-style: bold;
380
+ }
381
+
382
+ #stats-container {
383
+ background: transparent;
384
+ padding: 1 2;
385
+ margin-top: 1;
386
+ margin-bottom: 1;
387
+ border: round $panel;
388
+ }
389
+
390
+ .stat-row {
391
+ height: auto;
392
+ color: $subtext;
393
+ }
394
+
395
+ .stat-value {
396
+ color: $text;
397
+ text-style: bold;
398
+ }
399
+
400
+ #perf-container {
401
+ background: transparent;
402
+ color: $subtext;
403
+ padding: 1 1;
404
+ margin-top: 1;
405
+ border: round $panel;
406
+ }
407
+
408
+ #config-container {
409
+ background: transparent;
410
+ color: $subtext;
411
+ padding: 1 2;
412
+ margin-top: 1;
413
+ border: round $panel;
414
+ }
415
+
416
+ #device-info, #logging-status {
417
+ background: transparent;
418
+ text-align: left;
419
+ }
420
+
421
+ TranscriptionFlow {
422
+ width: 1fr;
423
+ padding: 1 2;
424
+ }
425
+
426
+ .area-title {
427
+ color: $accent;
428
+ text-style: bold;
429
+ margin-bottom: 1;
430
+ }
431
+
432
+ #final-log {
433
+ height: 1fr;
434
+ background: transparent;
435
+ border: round $panel;
436
+ margin-bottom: 1;
437
+ padding: 0 1;
438
+ scrollbar-background: $background;
439
+ scrollbar-color: $accent;
440
+ }
441
+
442
+ .logging-active, .log-on {
443
+ color: $green;
444
+ text-style: bold;
445
+ }
446
+
447
+ .sidebar-value-dim {
448
+ color: $subtext;
449
+ margin-left: 2;
450
+ }
451
+
452
+ #partial-buffer {
453
+ height: 5;
454
+ background: transparent;
455
+ border: round $accent;
456
+ padding: 1 2;
457
+ color: $secondary;
458
+ text-style: italic;
459
+ margin-top: 1;
460
+ }
461
+
462
+ /* Modal Styling */
463
+ SettingsScreen {
464
+ background: rgba(0, 0, 0, 0.8);
465
+ align: center middle;
466
+ }
467
+
468
+ #settings-form {
469
+ grid-size: 2;
470
+ grid-gutter: 1 2;
471
+ grid-columns: 1fr 2fr;
472
+ padding: 2 4;
473
+ width: 70;
474
+ height: auto;
475
+ background: $surface;
476
+ border: round $accent;
477
+ }
478
+
479
+ #settings-title {
480
+ column-span: 2;
481
+ text-align: center;
482
+ text-style: bold;
483
+ color: $accent;
484
+ margin-bottom: 2;
485
+ }
486
+
487
+ #input-api-key, #input-device-index {
488
+ background: transparent;
489
+ border: round $panel;
490
+ }
491
+
492
+ #settings-buttons {
493
+ column-span: 2;
494
+ margin-top: 2;
495
+ align-horizontal: right;
496
+ }
497
+
498
+ /* Export Modal Styling */
499
+ ExportScreen {
500
+ background: rgba(0, 0, 0, 0.8);
501
+ align: center middle;
502
+ }
503
+
504
+ #export-form {
505
+ padding: 2 4;
506
+ width: 60;
507
+ height: auto;
508
+ background: $surface;
509
+ border: round $accent;
510
+ }
511
+
512
+ #export-title {
513
+ text-align: center;
514
+ text-style: bold;
515
+ color: $accent;
516
+ margin-bottom: 1;
517
+ }
518
+
519
+ #export-buttons {
520
+ margin-top: 2;
521
+ align-horizontal: center;
522
+ height: 3;
523
+ }
524
+
525
+ #export-buttons Button {
526
+ margin: 0 1;
527
+ }
528
+ """
529
+
530
+ def compose(self) -> ComposeResult:
531
+ with Horizontal(id="main-container"):
532
+ yield Sidebar()
533
+ yield TranscriptionFlow()
534
+ yield Footer()
535
+
536
+ def on_mount(self):
537
+ self.settings = load_settings()
538
+ self.start_time = time.time()
539
+ self.turn_count = 0
540
+ self.word_count = 0
541
+ self.char_count = 0
542
+ self.total_confidence = 0.0
543
+ self.is_recording = False
544
+ self.current_worker = None
545
+ self.latency_sum = 0
546
+ self.latency_count = 0
547
+ self.session_log = []
548
+
549
+ self.log_widget = self.query_one("#final-log", RichLog)
550
+ self.partial_widget = self.query_one("#partial-buffer", Static)
551
+ self.status_widget = self.query_one("#status-label", Static)
552
+ self.duration_widget = self.query_one("#stat-duration", Label)
553
+ self.turns_widget = self.query_one("#stat-turns", Label)
554
+ self.words_widget = self.query_one("#stat-words", Label)
555
+ self.chars_widget = self.query_one("#stat-chars", Label)
556
+ self.accuracy_widget = self.query_one("#stat-accuracy", Label)
557
+ self.latency_widget = self.query_one("#stat-latency", Label)
558
+ self.vu_widget = self.query_one("#vu-meter", Label)
559
+ self.device_widget = self.query_one("#device-info", Label)
560
+ self.logging_widget = self.query_one("#logging-status", Label)
561
+
562
+ self.update_device_info()
563
+ self.update_status("IDLE", "waiting")
564
+ self.set_interval(1.0, self.update_stats)
565
+
566
+ def update_device_info(self):
567
+ audio = pyaudio.PyAudio()
568
+ idx = self.settings.get("device_index", 2)
569
+ try:
570
+ info = audio.get_device_info_by_index(idx)
571
+ # Use only first 15 chars of name for the sidebar
572
+ name = info['name'][:15] + "..." if len(info['name']) > 15 else info['name']
573
+ self.device_widget.update(f"[{idx}] {name}")
574
+ except Exception:
575
+ self.device_widget.update(f"Error {idx}")
576
+ audio.terminate()
577
+
578
+ def update_status(self, message: str, level: str = "active"):
579
+ self.status_widget.update(message.upper())
580
+ self.status_widget.remove_class("status-active", "status-error", "status-waiting")
581
+ self.status_widget.add_class(f"status-{level}")
582
+
583
+ def update_stats(self):
584
+ elapsed_seconds = int(time.time() - self.start_time)
585
+ if self.is_recording:
586
+ self.duration_widget.update(f"{elapsed_seconds}s")
587
+
588
+ self.turns_widget.update(str(self.turn_count))
589
+ self.words_widget.update(str(self.word_count))
590
+ self.chars_widget.update(str(self.char_count))
591
+ self.latency_widget.update(f"{self.latency}ms")
592
+
593
+ # Update VU Meter
594
+ bar_len = 10
595
+ filled = min(bar_len, int(self.volume / 10))
596
+ meter = "[" + "|" * filled + "." * (bar_len - filled) + "]"
597
+ self.vu_widget.update(meter)
598
+ if filled > 7:
599
+ self.vu_widget.styles.color = "#ef4444" # $red (high volume)
600
+ elif filled > 0:
601
+ self.vu_widget.styles.color = "#8b5cf6" # $accent
602
+ else:
603
+ self.vu_widget.styles.color = "#9ca3af" # $subtext
604
+
605
+ # Accuracy %
606
+ accuracy = (self.total_confidence / self.word_count * 100) if self.word_count > 0 else 100.0
607
+ self.accuracy_widget.update(f"{accuracy:.1f}%")
608
+
609
+ if self.settings.get("save_logs"):
610
+ self.logging_widget.update("ON")
611
+ self.logging_widget.add_class("log-on")
612
+ else:
613
+ self.logging_widget.update("OFF")
614
+ self.logging_widget.remove_class("log-on")
615
+
616
+ def action_clear_log(self):
617
+ self.log_widget.clear()
618
+
619
+ def action_open_settings(self):
620
+ if self.is_recording:
621
+ self.toggle_recording()
622
+
623
+ def handle_settings(new_settings):
624
+ if new_settings:
625
+ self.settings = new_settings
626
+ self.update_device_info()
627
+ self.log_widget.write(Text("Settings updated", style="dim green"))
628
+
629
+ self.push_screen(SettingsScreen(self.settings), handle_settings)
630
+
631
+ def action_export_session(self):
632
+ avg_latency = (self.latency_sum / self.latency_count) if self.latency_count > 0 else 0
633
+
634
+ # Calculate duration
635
+ elapsed = int(time.time() - self.start_time)
636
+ hrs, rem = divmod(elapsed, 3600)
637
+ mins, secs = divmod(rem, 60)
638
+ duration_str = f"{hrs}h {mins}m {secs}s" if hrs > 0 else f"{mins}m {secs}s"
639
+
640
+ # Calculate accuracy
641
+ accuracy = f"{(self.total_confidence / self.word_count * 100):.1f}%" if self.word_count > 0 else "0.0%"
642
+
643
+ stats = {
644
+ "duration": duration_str,
645
+ "turns": self.turn_count,
646
+ "words": self.word_count,
647
+ "chars": self.char_count,
648
+ "accuracy": accuracy,
649
+ "avg_latency": f"{avg_latency:.1f}ms",
650
+ "device": self.settings.get("audio_device_index", "Default"),
651
+ }
652
+
653
+ def handle_export(path):
654
+ if path:
655
+ try:
656
+ self.save_export(path, stats)
657
+ self.log_widget.write(Text(f"Session exported to {path}", style="bold green"))
658
+ except Exception as e:
659
+ self.log_widget.write(Text(f"Export failed: {str(e)}", style="bold red"))
660
+
661
+ self.push_screen(ExportScreen(stats), handle_export)
662
+
663
+ def save_export(self, path: str, stats: Dict[str, Any]):
664
+ # Gather all logs from the widget
665
+ # Textual's RichLog doesn't have a direct 'get_content' that returns markup-free text easily
666
+ # but we can reconstruct it from our own session data if we had it,
667
+ # or we can read the current log file if logging is on.
668
+ # For simplicity and robustness, we'll generate a beautiful report.
669
+
670
+ report = []
671
+ report.append(f"# Scribit Session Report - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
672
+ report.append("\n## Session Metrics")
673
+ report.append(f"- **Duration**: {stats['duration']}")
674
+ report.append(f"- **Turns**: {stats['turns']}")
675
+ report.append(f"- **Total Words**: {stats['words']}")
676
+ report.append(f"- **Total Characters**: {stats['chars']}")
677
+ report.append(f"- **Average Accurary**: {stats['accuracy']}")
678
+ report.append(f"- **Average Latency**: {stats['avg_latency']}")
679
+ report.append(f"- **Audio Device**: {stats['device']}")
680
+
681
+ report.append("\n## Transcription Log")
682
+ report.append("---")
683
+
684
+ # Export memory-buffered session log
685
+ if self.session_log:
686
+ report.append("\n".join(self.session_log))
687
+ else:
688
+ report.append("*No transcription for this session.*")
689
+
690
+ with open(path, "w", encoding="utf-8") as f:
691
+ f.write("\n".join(report))
692
+
693
+ def action_clear_log(self):
694
+ """Clears both the UI and the current session memory."""
695
+ self.log_widget.clear()
696
+ self.session_log = []
697
+ self.turn_count = 0
698
+ self.word_count = 0
699
+ self.char_count = 0
700
+ self.total_confidence = 0.0
701
+ self.latency_sum = 0
702
+ self.latency_count = 0
703
+ self.start_time = time.time()
704
+ self.update_stats()
705
+ self.log_widget.write(Text("Session cleared.", style="dim italic"))
706
+
707
+ def action_toggle_recording(self):
708
+ self.is_recording = not self.is_recording
709
+ if self.is_recording:
710
+ self.start_time = time.time()
711
+ self.update_status("CONNECTING", "waiting")
712
+ self.current_worker = self.run_worker(self.main_worker, thread=True)
713
+ self.partial_widget.update("Listening...")
714
+ else:
715
+ self.update_status("STOPPING", "waiting")
716
+ self.partial_widget.update("Paused")
717
+ # The worker will terminate itself when is_recording is False
718
+
719
+ def log_to_file(self, transcript: str):
720
+ if not self.settings.get("save_logs"):
721
+ return
722
+
723
+ filename = LOG_DIR / datetime.now().strftime("session_%Y-%m-%d.txt")
724
+ timestamp = datetime.now().strftime("[%H:%M:%S]")
725
+ with open(filename, "a", encoding="utf-8") as f:
726
+ f.write(f"{timestamp} {transcript}\n")
727
+
728
+ def main_worker(self):
729
+ api_key = self.settings.get("api_key")
730
+ if not api_key:
731
+ self.app.call_from_thread(self.update_status, "NO API KEY", "error")
732
+ self.log_widget.write(Text("Error: AssemblyAI API key missing in settings", style="bold red"))
733
+ self.is_recording = False
734
+ return
735
+
736
+ device_index = self.settings.get("device_index", 2)
737
+ if device_index is None: # Standardize if select was blank
738
+ device_index = 2
739
+
740
+ client = StreamingClient(
741
+ StreamingClientOptions(
742
+ api_key=api_key,
743
+ api_host="streaming.assemblyai.com",
744
+ )
745
+ )
746
+
747
+ client.on(StreamingEvents.Begin, self.on_begin)
748
+ client.on(StreamingEvents.Turn, self.on_turn)
749
+ client.on(StreamingEvents.Termination, self.on_terminated)
750
+ client.on(StreamingEvents.Error, self.on_error)
751
+
752
+ try:
753
+ client.connect(
754
+ StreamingParameters(
755
+ speech_model="u3-rt-pro",
756
+ sample_rate=16000,
757
+ )
758
+ )
759
+ except Exception as e:
760
+ self.app.call_from_thread(self.update_status, "CONN FAILED", "error")
761
+ self.log_widget.write(Text(f"Connection failed: {str(e)}", style="bold red"))
762
+ self.is_recording = False
763
+ return
764
+
765
+ try:
766
+ self.app.call_from_thread(self.update_status, "RECORDING", "active")
767
+ with SystemAudioStream(device_index=device_index) as audio_stream:
768
+ for chunk in audio_stream:
769
+ if not self.is_recording:
770
+ break
771
+
772
+ # Calculate volume for VU Meter (native replacement for audioop.rms)
773
+ count = len(chunk) // 2
774
+ if count > 0:
775
+ shorts = struct.unpack(f"<{count}h", chunk)
776
+ sum_squares = sum(s**2 for s in shorts)
777
+ rms = math.sqrt(sum_squares / count)
778
+ self.volume = min(100, int((rms / 4000) * 100))
779
+ else:
780
+ self.volume = 0
781
+
782
+ self.last_chunk_time = time.time()
783
+ client.stream(chunk)
784
+ except Exception as e:
785
+ self.app.call_from_thread(self.update_status, "STREAM ERROR", "error")
786
+ self.log_widget.write(Text(f"Audio error: {str(e)}", style="bold red"))
787
+ finally:
788
+ self.is_recording = False
789
+ try:
790
+ client.disconnect(terminate=True)
791
+ except:
792
+ pass
793
+ self.app.call_from_thread(self.update_status, "IDLE", "waiting")
794
+
795
+ def on_begin(self, client, event: BeginEvent):
796
+ pass
797
+
798
+ def on_turn(self, client, event: TurnEvent):
799
+ if not event.transcript:
800
+ return
801
+
802
+ # Calculate Latency for final transcripts
803
+ if event.end_of_turn and self.last_chunk_time > 0:
804
+ calc_latency = int((time.time() - self.last_chunk_time) * 1000)
805
+ cur_latency = min(999, calc_latency)
806
+ self.latency = cur_latency
807
+ self.latency_sum += cur_latency
808
+ self.latency_count += 1
809
+
810
+ if event.end_of_turn:
811
+ self.turn_count += 1
812
+ words_list = event.transcript.split()
813
+ self.word_count += len(words_list)
814
+ self.char_count += len(event.transcript)
815
+
816
+ # Update confidence (Accuracy)
817
+ if hasattr(event, "words") and event.words:
818
+ self.total_confidence += sum(w.confidence for w in event.words)
819
+ else:
820
+ # Fallback to high confidence if word-level data is missing
821
+ self.total_confidence += len(words_list) * 0.95
822
+
823
+ timestamp = time.strftime("%H:%M:%S")
824
+ # Custom styling for the transcript lines
825
+ line_str = f"[{timestamp}] {event.transcript}"
826
+ self.session_log.append(line_str)
827
+
828
+ line = Text.assemble(
829
+ (f"[{timestamp}] ", "dim"),
830
+ ("❯❯ ", "bold #8b5cf6"),
831
+ (f"{event.transcript}", "#f3f4f6")
832
+ )
833
+ self.app.call_from_thread(self.log_widget.write, line)
834
+ self.app.call_from_thread(self.partial_widget.update, "")
835
+ self.log_to_file(event.transcript)
836
+ else:
837
+ self.app.call_from_thread(self.partial_widget.update, event.transcript)
838
+
839
+ def on_terminated(self, client, event: TerminationEvent):
840
+ pass
841
+
842
+ def on_error(self, client, event: StreamingError):
843
+ error_msg = str(event)
844
+ self.log_widget.write(Text(f"API Error: {error_msg}", style="bold red"))
845
+ self.app.call_from_thread(self.update_status, "ERROR", "error")
846
+
847
+
848
+ def main():
849
+ app = ScribitApp()
850
+ app.run()
851
+
852
+
853
+ if __name__ == "__main__":
854
+ main()
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: scribit
3
+ Version: 0.1.0
4
+ Summary: Real-time Transcription TUI powered by AssemblyAI
5
+ Project-URL: Homepage, https://github.com/leo01102/scribit
6
+ Project-URL: Issues, https://github.com/leo01102/scribit/issues
7
+ Project-URL: Repository, https://github.com/leo01102/scribit
8
+ Author-email: leo01102 <leo01102@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: assemblyai,audio,real-time,textual,transcription,tui
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Capture/Recording
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: assemblyai>=0.30.0
22
+ Requires-Dist: platformdirs>=4.0.0
23
+ Requires-Dist: pyaudio>=0.2.14
24
+ Requires-Dist: python-dotenv>=1.0.0
25
+ Requires-Dist: rich>=13.0.0
26
+ Requires-Dist: textual>=0.50.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: hatch>=1.12.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
30
+ Requires-Dist: textual-dev>=1.0.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # Scribit
34
+
35
+ [![PyPI version](https://badge.fury.io/py/scribit.svg)](https://badge.fury.io/py/scribit)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
37
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/release/python-3100/)
38
+
39
+ Scribit is a high-performance real-time audio transcription engine with a premium developer-focused terminal interface. Powered by AssemblyAI's Streaming API and the Textual framework, it provides an elegant way to capture and log speech instantly.
40
+
41
+ ## Features
42
+
43
+ - **Sleek TUI**: A dark-themed terminal interface built with Textual.
44
+ - **Real-time Transcription**: Powered by AssemblyAI's Streaming API.
45
+ - **Toggle Recording**: Start and stop transcription dynamically using the `Space` key.
46
+ - **In-App Settings**: Change your API key, audio device, and logging preferences without restarting.
47
+ - **Persistent Configuration**: Settings are saved locally in `settings.json`.
48
+ - **Transcript Logging**: Option to automatically save session transcripts to timestamped files.
49
+ - **Session Stats**: Monitor duration and turn count in real-time.
50
+
51
+ ### From PyPI
52
+ ```bash
53
+ pip install scribit
54
+ ```
55
+
56
+ ### From Source
57
+ 1. **Clone the repository**:
58
+ ```bash
59
+ git clone https://github.com/leo01102/scribit.git
60
+ cd scribit
61
+ ```
62
+ 3. **Install the package**:
63
+ ```bash
64
+ pip install .
65
+ ```
66
+ Alternatively, for development use:
67
+ ```bash
68
+ pip install -e ".[dev]"
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ Once installed, simply run the following command in your terminal:
74
+ ```bash
75
+ scribit
76
+ ```
77
+
78
+ ### Controls
79
+
80
+ 1. **Configure API Key**:
81
+ - Press `S` to open the Settings menu.
82
+ - Paste your AssemblyAI API key and press `Save`.
83
+ 2. **Start Recording**: Press `Space` to begin transcription.
84
+ 3. **Stop Recording**: Press `Space` again to pause/stop.
85
+ 4. **Clear Log**: Press `C` to clear the current transcription log.
86
+ 5. **Quit**: Press `Q` to exit the application.
87
+
88
+ ### Key Bindings
89
+
90
+ | Key | Action |
91
+ | :------ | :--------------------- |
92
+ | `Space` | Start/Stop Recording |
93
+ | `S` | Open Settings Menu |
94
+ | `D` | Download Session (.md) |
95
+ | `C` | Clear Session Memory |
96
+ | `Q` | Quit Application |
97
+
98
+ ### Storage Location
99
+ Scribit now follows platform standards for data storage:
100
+ - **Windows**: `AppData/Local/scribit`
101
+ - **macOS**: `~/Library/Application Support/scribit`
102
+ - **Linux**: `~/.config/scribit`
103
+
104
+ ## Technical Details
105
+
106
+ - **Transcription Engine**: AssemblyAI Streaming (v3).
107
+ - **Default Device**: Set to index 2 (configurable in settings).
108
+ - **Logs**: Saved in the `logs/` directory if enabled.
109
+ - **Environment**: Initial API keys can be loaded from a `.env` file.
110
+
111
+ ## License
112
+
113
+ This project is licensed under the MIT License.
@@ -0,0 +1,7 @@
1
+ scribit/__init__.py,sha256=W5j7TF6G5m8DwV3D6K9E7WeA2RAONovZ0f_fLSStVlg,91
2
+ scribit/main.py,sha256=0S8NRdK476cdxsYK89-CPwFXOQQy_vOnV2hTRyH_wSw,28518
3
+ scribit-0.1.0.dist-info/METADATA,sha256=l0mDkFQIqVET-PP-UpT51Dyp0nkdLLFu7Faf4J-Bm0c,4092
4
+ scribit-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
+ scribit-0.1.0.dist-info/entry_points.txt,sha256=Yp8jvGXEX8ecYxIwqNUzwRfUcOhOrBj57kqBZZTXqPQ,46
6
+ scribit-0.1.0.dist-info/licenses/LICENSE,sha256=D9t3XgDm_d4jmEtPEYTNE8NBNcqsHnFK1IkUHCOXBLw,1060
7
+ scribit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scribit = scribit.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.