scribit 0.1.0__tar.gz

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.
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Set up Python ${{ matrix.python-version }}
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+
23
+ - name: Install dependencies
24
+ run: |
25
+ python -m pip install --upgrade pip
26
+ pip install hatch
27
+ hatch shell -- pip install .[test]
28
+
29
+ - name: Run tests
30
+ run: |
31
+ hatch run pytest
@@ -0,0 +1,64 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ build:
10
+ name: Build distribution
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - name: Set up Python
16
+ uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.10"
19
+ - name: Install hatch
20
+ run: python -m pip install hatch
21
+ - name: Build a binary wheel and a source tarball
22
+ run: hatch build
23
+ - name: Store the distribution packages
24
+ uses: actions/upload-artifact@v4
25
+ with:
26
+ name: python-package-distributions
27
+ path: dist/
28
+
29
+ publish-to-pypi:
30
+ name: Publish Python distribution to PyPI
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ permissions:
34
+ contents: read
35
+
36
+ steps:
37
+ - name: Download all the distributions
38
+ uses: actions/download-artifact@v4
39
+ with:
40
+ name: python-package-distributions
41
+ path: dist/
42
+ - name: Publish distribution to PyPI
43
+ uses: pypa/gh-action-pypi-publish@release/v1
44
+ with:
45
+ password: ${{ secrets.PYPI_API_TOKEN }}
46
+
47
+ github-release:
48
+ name: Create GitHub Release
49
+ needs: publish-to-pypi
50
+ runs-on: ubuntu-latest
51
+ permissions:
52
+ contents: write
53
+
54
+ steps:
55
+ - name: Download all the distributions
56
+ uses: actions/download-artifact@v4
57
+ with:
58
+ name: python-package-distributions
59
+ path: dist/
60
+ - name: Create Release
61
+ uses: softprops/action-gh-release@v2
62
+ with:
63
+ files: dist/*
64
+ generate_release_notes: true
@@ -0,0 +1,138 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so may be deleted later.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesistool/
51
+ .pytest_cache/
52
+ pytestdebug/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or binary, you should probably not check in .python-version
87
+ .python-version
88
+
89
+ # pipenv
90
+ # According to pypa/pipenv#1191, it is recommended to not check in Pipfile.lock anymore
91
+ # if you are developing a library.
92
+ # For apps, you should check in Pipfile.lock.
93
+ # Pipfile.lock
94
+
95
+ # poetry
96
+ # Similar to Pipfile.lock, it is generally recommended to check poetry.lock in for applications.
97
+ # poetry.lock
98
+
99
+ # pdm
100
+ # Similar to Pipfile.lock, it is generally recommended to check pdm.lock in for applications.
101
+ # pdm.lock
102
+
103
+ # virtualenv
104
+ .venv/
105
+ venv/
106
+ ENV/
107
+ env/
108
+
109
+ # spyder project settings
110
+ .spyderproject
111
+ .spyproject
112
+
113
+ # Rope project settings
114
+ .ropeproject
115
+
116
+ # mkdocs documentation
117
+ /site
118
+
119
+ # mypy
120
+ .mypy_cache/
121
+ .dmypy.json
122
+ dmypy.json
123
+
124
+ # Pyre type checker
125
+ .pyre/
126
+
127
+ # pytype static type analyzer
128
+ .pytype/
129
+
130
+ # Cython debug symbols
131
+ cython_debug/
132
+
133
+ # Project Specific
134
+ .env
135
+ devices.txt
136
+ logs/
137
+ *.transcripts.txt
138
+ settings.json
scribit-0.1.0/LICENSE ADDED
@@ -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.
scribit-0.1.0/PKG-INFO ADDED
@@ -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,81 @@
1
+ # Scribit
2
+
3
+ [![PyPI version](https://badge.fury.io/py/scribit.svg)](https://badge.fury.io/py/scribit)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/release/python-3100/)
6
+
7
+ 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.
8
+
9
+ ## Features
10
+
11
+ - **Sleek TUI**: A dark-themed terminal interface built with Textual.
12
+ - **Real-time Transcription**: Powered by AssemblyAI's Streaming API.
13
+ - **Toggle Recording**: Start and stop transcription dynamically using the `Space` key.
14
+ - **In-App Settings**: Change your API key, audio device, and logging preferences without restarting.
15
+ - **Persistent Configuration**: Settings are saved locally in `settings.json`.
16
+ - **Transcript Logging**: Option to automatically save session transcripts to timestamped files.
17
+ - **Session Stats**: Monitor duration and turn count in real-time.
18
+
19
+ ### From PyPI
20
+ ```bash
21
+ pip install scribit
22
+ ```
23
+
24
+ ### From Source
25
+ 1. **Clone the repository**:
26
+ ```bash
27
+ git clone https://github.com/leo01102/scribit.git
28
+ cd scribit
29
+ ```
30
+ 3. **Install the package**:
31
+ ```bash
32
+ pip install .
33
+ ```
34
+ Alternatively, for development use:
35
+ ```bash
36
+ pip install -e ".[dev]"
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ Once installed, simply run the following command in your terminal:
42
+ ```bash
43
+ scribit
44
+ ```
45
+
46
+ ### Controls
47
+
48
+ 1. **Configure API Key**:
49
+ - Press `S` to open the Settings menu.
50
+ - Paste your AssemblyAI API key and press `Save`.
51
+ 2. **Start Recording**: Press `Space` to begin transcription.
52
+ 3. **Stop Recording**: Press `Space` again to pause/stop.
53
+ 4. **Clear Log**: Press `C` to clear the current transcription log.
54
+ 5. **Quit**: Press `Q` to exit the application.
55
+
56
+ ### Key Bindings
57
+
58
+ | Key | Action |
59
+ | :------ | :--------------------- |
60
+ | `Space` | Start/Stop Recording |
61
+ | `S` | Open Settings Menu |
62
+ | `D` | Download Session (.md) |
63
+ | `C` | Clear Session Memory |
64
+ | `Q` | Quit Application |
65
+
66
+ ### Storage Location
67
+ Scribit now follows platform standards for data storage:
68
+ - **Windows**: `AppData/Local/scribit`
69
+ - **macOS**: `~/Library/Application Support/scribit`
70
+ - **Linux**: `~/.config/scribit`
71
+
72
+ ## Technical Details
73
+
74
+ - **Transcription Engine**: AssemblyAI Streaming (v3).
75
+ - **Default Device**: Set to index 2 (configurable in settings).
76
+ - **Logs**: Saved in the `logs/` directory if enabled.
77
+ - **Environment**: Initial API keys can be loaded from a `.env` file.
78
+
79
+ ## License
80
+
81
+ This project is licensed under the MIT License.
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "scribit"
3
+ dynamic = ["version"]
4
+ description = "Real-time Transcription TUI powered by AssemblyAI"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "leo01102", email = "leo01102@users.noreply.github.com" },
10
+ ]
11
+ keywords = ["transcription", "audio", "tui", "assemblyai", "textual", "real-time"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Topic :: Multimedia :: Sound/Audio :: Capture/Recording",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+ dependencies = [
23
+ "assemblyai>=0.30.0",
24
+ "python-dotenv>=1.0.0",
25
+ "pyaudio>=0.2.14",
26
+ "textual>=0.50.0",
27
+ "rich>=13.0.0",
28
+ "platformdirs>=4.0.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "hatch>=1.12.0",
35
+ "textual-dev>=1.0.0",
36
+ ]
37
+
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/leo01102/scribit"
41
+ Issues = "https://github.com/leo01102/scribit/issues"
42
+ Repository = "https://github.com/leo01102/scribit"
43
+
44
+ [project.scripts]
45
+ scribit = "scribit.main:main"
46
+
47
+ [build-system]
48
+ requires = ["hatchling"]
49
+ build-backend = "hatchling.build"
50
+
51
+ [tool.hatch.version]
52
+ path = "src/scribit/__init__.py"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/scribit"]
56
+
@@ -0,0 +1,6 @@
1
+ assemblyai
2
+ platformdirs
3
+ python-dotenv
4
+ pyaudio
5
+ textual
6
+ rich
@@ -0,0 +1,4 @@
1
+ __version__ = "0.1.0"
2
+ from .main import ScribitApp, main
3
+
4
+ __all__ = ["ScribitApp", "main"]
@@ -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,14 @@
1
+ import pytest
2
+ from scribit.main import ScribitApp
3
+
4
+ def test_app_metadata():
5
+ """Test that the app has the correct metadata."""
6
+ app = ScribitApp()
7
+ assert app.TITLE == "SCRIBIT"
8
+ assert "Real-time" in app.SUB_TITLE
9
+
10
+ def test_imports():
11
+ """Ensure main components can be imported."""
12
+ from scribit.main import load_settings, ScribitApp
13
+ assert load_settings is not None
14
+ assert ScribitApp is not None