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.
- scribit-0.1.0/.github/workflows/ci.yml +31 -0
- scribit-0.1.0/.github/workflows/release.yml +64 -0
- scribit-0.1.0/.gitignore +138 -0
- scribit-0.1.0/LICENSE +21 -0
- scribit-0.1.0/PKG-INFO +113 -0
- scribit-0.1.0/README.md +81 -0
- scribit-0.1.0/pyproject.toml +56 -0
- scribit-0.1.0/requirements.txt +6 -0
- scribit-0.1.0/src/scribit/__init__.py +4 -0
- scribit-0.1.0/src/scribit/main.py +854 -0
- scribit-0.1.0/tests/test_basic.py +14 -0
|
@@ -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
|
scribit-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
[](https://badge.fury.io/py/scribit)
|
|
36
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
[](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.
|
scribit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Scribit
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/py/scribit)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](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,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
|