stt2desktop 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """
2
+ stt2desktop
3
+ local STT for KDE using faster-whisper
4
+ """
5
+
6
+ # See https://packaging.python.org/en/latest/specifications/version-specifiers/
7
+ __version__ = '0.0.1'
8
+ __author__ = 'Jens Diemer <stt2desktop@jensdiemer.de>'
@@ -0,0 +1,10 @@
1
+ """
2
+ Allow stt2desktop to be executable
3
+ through `python -m stt2desktop`.
4
+ """
5
+
6
+ from stt2desktop.cli_app import main
7
+
8
+
9
+ if __name__ == '__main__':
10
+ main()
@@ -0,0 +1,41 @@
1
+ """
2
+ CLI for usage
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from cli_base.autodiscover import import_all_files
10
+ from cli_base.cli_tools.version_info import print_version
11
+ from rich import print # noqa
12
+ from tyro.extras import SubcommandApp
13
+
14
+ import stt2desktop
15
+ from stt2desktop import constants
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ app = SubcommandApp()
21
+
22
+ # Register all CLI commands, just by import all files in this package:
23
+ import_all_files(package=__package__, init_file=__file__)
24
+
25
+
26
+ @app.command
27
+ def version():
28
+ """Print version and exit"""
29
+ # Pseudo command, because the version always printed on every CLI call ;)
30
+ sys.exit(0)
31
+
32
+
33
+ def main(args: Sequence[str] | None = None):
34
+ print_version(stt2desktop)
35
+ app.cli(
36
+ prog='./cli.py',
37
+ description=constants.CLI_EPILOG,
38
+ use_underscores=False, # use hyphens instead of underscores
39
+ sort_subcommands=True,
40
+ args=args,
41
+ )
@@ -0,0 +1,85 @@
1
+ import logging
2
+ from typing import Annotated
3
+
4
+ import tyro
5
+ from cli_base.cli_tools.verbosity import setup_logging
6
+ from cli_base.tyro_commands import TyroVerbosityArgType
7
+ from pynput import keyboard
8
+ from rich import print
9
+
10
+ from stt2desktop.cli_app import app
11
+ from stt2desktop.constants import (
12
+ DEFAULT_HOTKEY,
13
+ DEFAULT_SAMPLE_RATE,
14
+ DEFAULT_WHISPER_COMPUTE_TYPE,
15
+ DEFAULT_WHISPER_DEVICE,
16
+ DEFAULT_WHISPER_MODEL,
17
+ DEFAULT_WHISPER_NUM_WORKERS,
18
+ IS_WAYLAND,
19
+ WhisperModel,
20
+ )
21
+ from stt2desktop.get_bin import get_wtype_bin, get_xdotool_bin
22
+ from stt2desktop.stt import Recorder, load_whisper_model
23
+
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @app.command
29
+ def listen(
30
+ verbosity: TyroVerbosityArgType,
31
+ model: Annotated[
32
+ WhisperModel, tyro.conf.arg(help='Whisper model to use for transcription.')
33
+ ] = DEFAULT_WHISPER_MODEL,
34
+ hotkey: Annotated[
35
+ keyboard.Key,
36
+ tyro.conf.arg(help='Key to hold for recording. Release to transcribe and insert text.'),
37
+ ] = DEFAULT_HOTKEY,
38
+ sample_rate: Annotated[
39
+ int, tyro.conf.arg(help='Audio sample rate in Hz. Whisper expects 16000.')
40
+ ] = DEFAULT_SAMPLE_RATE,
41
+ device: Annotated[
42
+ str, tyro.conf.arg(help='Device to run inference on, e.g. cpu or cuda.')
43
+ ] = DEFAULT_WHISPER_DEVICE,
44
+ compute_type: Annotated[
45
+ str, tyro.conf.arg(help='Quantization type, e.g. int8, float16, float32.')
46
+ ] = DEFAULT_WHISPER_COMPUTE_TYPE,
47
+ num_workers: Annotated[
48
+ int,
49
+ tyro.conf.arg(help='Number of parallel transcription workers. Defaults to CPU count.'),
50
+ ] = DEFAULT_WHISPER_NUM_WORKERS,
51
+ ):
52
+ """Start the STT listener. Hold the hotkey to record, release to transcribe and insert."""
53
+ setup_logging(verbosity=verbosity)
54
+
55
+ # Before start, check if needed binaries are available:
56
+ if IS_WAYLAND:
57
+ get_wtype_bin()
58
+ else:
59
+ get_xdotool_bin()
60
+
61
+ model = load_whisper_model(
62
+ model_size=model,
63
+ device=device,
64
+ compute_type=compute_type,
65
+ num_workers=num_workers,
66
+ )
67
+ recorder = Recorder(model=model, sample_rate=sample_rate)
68
+
69
+ def on_press(key):
70
+ if key == hotkey:
71
+ recorder.start()
72
+
73
+ def on_release(key):
74
+ if key == hotkey:
75
+ recorder.stop()
76
+
77
+ print(f'Ready. Hold [bold]{hotkey.name}[/bold] to record, release to transcribe.')
78
+ print('Press Ctrl+C to exit.')
79
+ try:
80
+ with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
81
+ listener.join()
82
+ except KeyboardInterrupt:
83
+ print('\nExiting...')
84
+ finally:
85
+ recorder.stop()
@@ -0,0 +1,70 @@
1
+ """
2
+ CLI for development
3
+ """
4
+
5
+ import importlib
6
+ import logging
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ from bx_py_utils.path import assert_is_file
11
+ from cli_base.autodiscover import import_all_files
12
+ from cli_base.cli_tools.dev_tools import run_coverage, run_nox, run_unittest_cli
13
+ from cli_base.cli_tools.version_info import print_version
14
+ from typeguard import install_import_hook
15
+ from tyro.extras import SubcommandApp
16
+
17
+ import stt2desktop
18
+ from stt2desktop import constants
19
+
20
+
21
+ # Check type annotations via typeguard in all tests.
22
+ # Sadly we must activate this here and can't do this in ./tests/__init__.py
23
+ install_import_hook(packages=('stt2desktop',))
24
+
25
+ # reload the module, after the typeguard import hook is activated:
26
+ importlib.reload(stt2desktop)
27
+
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ PACKAGE_ROOT = constants.BASE_PATH.parent
33
+ assert_is_file(PACKAGE_ROOT / 'pyproject.toml') # Exists only in cloned git repo
34
+
35
+
36
+ app = SubcommandApp()
37
+
38
+
39
+ # Register all CLI commands, just by import all files in this package:
40
+ import_all_files(package=__package__, init_file=__file__)
41
+
42
+
43
+ @app.command
44
+ def version():
45
+ """Print version and exit"""
46
+ # Pseudo command, because the version always printed on every CLI call ;)
47
+ sys.exit(0)
48
+
49
+
50
+ def main(args: Sequence[str] | None = None):
51
+ print_version(stt2desktop)
52
+
53
+ if len(sys.argv) >= 2:
54
+ # Check if we can just pass a command call to origin CLI:
55
+ command = sys.argv[1]
56
+ command_map = {
57
+ 'test': run_unittest_cli,
58
+ 'nox': run_nox,
59
+ 'coverage': run_coverage,
60
+ }
61
+ if real_func := command_map.get(command):
62
+ real_func(argv=sys.argv, exit_after_run=True)
63
+
64
+ app.cli(
65
+ prog='./dev-cli.py',
66
+ description=constants.CLI_EPILOG,
67
+ use_underscores=False, # use hyphens instead of underscores
68
+ sort_subcommands=True,
69
+ args=args,
70
+ )
@@ -0,0 +1,10 @@
1
+ """
2
+ Allow stt2desktop to be executable
3
+ through `python -m stt2desktop.cli_dev`.
4
+ """
5
+
6
+ from stt2desktop.cli_dev import main
7
+
8
+
9
+ if __name__ == '__main__':
10
+ main()
@@ -0,0 +1,12 @@
1
+ from cli_base.cli_tools.code_style import assert_code_style
2
+ from cli_base.tyro_commands import TyroVerbosityArgType
3
+
4
+ from stt2desktop.cli_dev import PACKAGE_ROOT, app
5
+
6
+
7
+ @app.command
8
+ def lint(verbosity: TyroVerbosityArgType = 1):
9
+ """
10
+ Check/fix code style by run: "ruff check --fix"
11
+ """
12
+ assert_code_style(package_root=PACKAGE_ROOT, verbose=bool(verbosity), sys_exit=True)
@@ -0,0 +1,62 @@
1
+ import logging
2
+
3
+ from cli_base.cli_tools.dev_tools import run_unittest_cli
4
+ from cli_base.cli_tools.subprocess_utils import ToolsExecutor
5
+ from cli_base.cli_tools.verbosity import setup_logging
6
+ from cli_base.run_pip_audit import run_pip_audit
7
+ from cli_base.tyro_commands import TyroVerbosityArgType
8
+ from manageprojects.utilities.publish import publish_package
9
+
10
+ import stt2desktop
11
+ from stt2desktop.cli_dev import PACKAGE_ROOT, app
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @app.command
18
+ def install():
19
+ """
20
+ Install requirements and 'stt2desktop' via pip as editable.
21
+ """
22
+ tools_executor = ToolsExecutor(cwd=PACKAGE_ROOT)
23
+ tools_executor.verbose_check_call('uv', 'sync')
24
+ tools_executor.verbose_check_call('uv', 'pip', 'install', '--no-deps', '-e', '.')
25
+
26
+
27
+ @app.command
28
+ def pip_audit(verbosity: TyroVerbosityArgType):
29
+ """
30
+ Run pip-audit check against current requirements files
31
+ """
32
+ setup_logging(verbosity=verbosity)
33
+ run_pip_audit(base_path=PACKAGE_ROOT, verbosity=verbosity)
34
+
35
+
36
+ @app.command
37
+ def update(verbosity: TyroVerbosityArgType):
38
+ """
39
+ Update dependencies (uv.lock) and git pre-commit hooks
40
+ """
41
+ setup_logging(verbosity=verbosity)
42
+
43
+ tools_executor = ToolsExecutor(cwd=PACKAGE_ROOT)
44
+ tools_executor.verbose_check_call('uv', 'lock', '--upgrade')
45
+
46
+ run_pip_audit(base_path=PACKAGE_ROOT, verbosity=verbosity)
47
+
48
+ # Install new dependencies in current .venv:
49
+ tools_executor.verbose_check_call('uv', 'sync')
50
+
51
+ # Update git pre-commit hooks:
52
+ tools_executor.verbose_check_call('pre-commit', 'autoupdate')
53
+
54
+
55
+ @app.command
56
+ def publish():
57
+ """
58
+ Build and upload this project to PyPi
59
+ """
60
+ run_unittest_cli(verbose=False, exit_after_run=False) # Don't publish a broken state
61
+
62
+ publish_package(module=stt2desktop, package_path=PACKAGE_ROOT)
@@ -0,0 +1,52 @@
1
+ from cli_base.cli_tools.dev_tools import run_coverage, run_nox, run_unittest_cli
2
+ from cli_base.cli_tools.subprocess_utils import verbose_check_call
3
+ from cli_base.cli_tools.test_utils.snapshot import UpdateTestSnapshotFiles
4
+ from cli_base.tyro_commands import TyroVerbosityArgType
5
+
6
+ from stt2desktop.cli_dev import PACKAGE_ROOT, app
7
+
8
+
9
+ @app.command
10
+ def mypy(verbosity: TyroVerbosityArgType):
11
+ """Run Mypy (configured in pyproject.toml)"""
12
+ verbose_check_call('mypy', '.', cwd=PACKAGE_ROOT, verbose=verbosity > 0, exit_on_error=True)
13
+
14
+
15
+ @app.command
16
+ def update_test_snapshot_files(verbosity: TyroVerbosityArgType):
17
+ """
18
+ Update all test snapshot files (by remove and recreate all snapshot files)
19
+ """
20
+ with UpdateTestSnapshotFiles(root_path=PACKAGE_ROOT, verbose=verbosity > 0):
21
+ # Just recreate them by running tests:
22
+ run_unittest_cli(
23
+ extra_env={
24
+ 'RAISE_SNAPSHOT_ERRORS': '0', # Recreate snapshot files without error
25
+ },
26
+ verbose=verbosity > 1,
27
+ exit_after_run=False,
28
+ )
29
+
30
+
31
+ @app.command # Dummy command
32
+ def test():
33
+ """
34
+ Run unittests
35
+ """
36
+ run_unittest_cli()
37
+
38
+
39
+ @app.command # Dummy command
40
+ def coverage():
41
+ """
42
+ Run tests and show coverage report.
43
+ """
44
+ run_coverage()
45
+
46
+
47
+ @app.command # Dummy "nox" command
48
+ def nox():
49
+ """
50
+ Run nox
51
+ """
52
+ run_nox()
@@ -0,0 +1,33 @@
1
+ import logging
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from cli_base.cli_tools import git_history
6
+ from cli_base.cli_tools.verbosity import setup_logging
7
+ from cli_base.tyro_commands import TyroVerbosityArgType
8
+ from rich import print
9
+
10
+ from stt2desktop.cli_dev import app
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @app.command
17
+ def update_readme_history(verbosity: TyroVerbosityArgType):
18
+ """
19
+ Update project history base on git commits/tags in README.md
20
+
21
+ Will be exited with 1 if the README.md was updated otherwise with 0.
22
+
23
+ Also, callable via e.g.:
24
+ python -m cli_base update-readme-history -v
25
+ """
26
+ setup_logging(verbosity=verbosity)
27
+
28
+ logger.debug('%s called. CWD: %s', __name__, Path.cwd())
29
+ updated = git_history.update_readme_history(verbosity=verbosity)
30
+ exit_code = 1 if updated else 0
31
+ if verbosity:
32
+ print(f'{exit_code=}')
33
+ sys.exit(exit_code)
@@ -0,0 +1,28 @@
1
+ import os
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+
5
+ from faster_whisper.utils import available_models
6
+ from pynput import keyboard
7
+
8
+ import stt2desktop
9
+
10
+
11
+ CLI_EPILOG = 'Project Homepage: https://github.com/jedie/stt2desktop'
12
+
13
+ BASE_PATH = Path(stt2desktop.__file__).parent
14
+
15
+ IS_WAYLAND = bool(os.environ.get('WAYLAND_DISPLAY'))
16
+
17
+ WhisperModel = StrEnum(
18
+ 'WhisperModel',
19
+ [(m.replace('-', '_').replace('.', '_'), m) for m in available_models()],
20
+ )
21
+
22
+ # Default values used in the CLI
23
+ DEFAULT_WHISPER_MODEL = WhisperModel.small
24
+ DEFAULT_WHISPER_DEVICE = 'auto' # Let faster-whisper decide the best device
25
+ DEFAULT_WHISPER_COMPUTE_TYPE = 'int8'
26
+ DEFAULT_WHISPER_NUM_WORKERS = os.cpu_count() or 1
27
+ DEFAULT_SAMPLE_RATE = 16000
28
+ DEFAULT_HOTKEY = keyboard.Key.scroll_lock
stt2desktop/get_bin.py ADDED
@@ -0,0 +1,29 @@
1
+ import functools
2
+ import shutil
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from cli_base.cli_tools.subprocess_utils import verbose_check_call
7
+
8
+
9
+ def get_bin(name: str, hint: str) -> Path:
10
+ bin_path = shutil.which(name)
11
+ if bin_path is None:
12
+ print(f'[red]Error: {name} is not installed or not found in PATH.[/red]')
13
+ print(hint)
14
+ sys.exit(1)
15
+ return Path(bin_path)
16
+
17
+
18
+ @functools.cache
19
+ def get_wtype_bin() -> Path:
20
+ wtype_bin = get_bin('wtype', hint='wtype is required for Wayland support: sudo apt install wtype')
21
+ verbose_check_call(wtype_bin, 'version')
22
+ return wtype_bin
23
+
24
+
25
+ @functools.cache
26
+ def get_xdotool_bin() -> Path:
27
+ xdotool_bin = get_bin('xdotool', hint='xdotool is required for X11 support: sudo apt install xdotool')
28
+ verbose_check_call(xdotool_bin, 'version')
29
+ return xdotool_bin
stt2desktop/stt.py ADDED
@@ -0,0 +1,107 @@
1
+ import logging
2
+ import subprocess
3
+ import threading
4
+ from concurrent.futures import Future, ThreadPoolExecutor
5
+
6
+ import numpy as np
7
+ import sounddevice as sd
8
+ from faster_whisper import WhisperModel
9
+ from rich import print
10
+
11
+ from stt2desktop.constants import IS_WAYLAND
12
+ from stt2desktop.get_bin import get_wtype_bin, get_xdotool_bin
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def load_whisper_model(*, model_size: str, device: str, compute_type: str, num_workers: int) -> WhisperModel:
19
+ print(f'Loading model {model_size} ({device=} {compute_type=} {num_workers=})...')
20
+ model = WhisperModel(model_size, device=device, compute_type=compute_type, num_workers=num_workers)
21
+ print('[green]Model loaded.[/green]')
22
+ return model
23
+
24
+
25
+ def transcribe(model: WhisperModel, audio: np.ndarray) -> str:
26
+ if len(audio) == 0:
27
+ return ''
28
+ logger.debug('Transcribing %d samples', len(audio))
29
+ segments, _ = model.transcribe(audio, beam_size=5)
30
+ result = ' '.join(seg.text for seg in segments).strip()
31
+ logger.debug('Transcription: %r', result)
32
+ return result
33
+
34
+
35
+ def record_until_event(*, stop_event: threading.Event, sample_rate: int) -> np.ndarray:
36
+ """Record from the default microphone until stop_event is set."""
37
+ chunks: list[np.ndarray] = []
38
+
39
+ def callback(indata, frames, time, status):
40
+ if status:
41
+ logger.warning('Audio input status: %r', status)
42
+ chunks.append(indata.copy())
43
+
44
+ logger.debug('Recording started')
45
+ with sd.InputStream(samplerate=sample_rate, channels=1, dtype='float32', callback=callback):
46
+ stop_event.wait()
47
+ logger.debug('Recording stopped, got %d chunks', len(chunks))
48
+
49
+ if not chunks:
50
+ return np.array([], dtype='float32')
51
+ return np.concatenate(chunks, axis=0).flatten()
52
+
53
+
54
+ def inject_text(text: str) -> None:
55
+ """Inject text at the current cursor position."""
56
+ if IS_WAYLAND:
57
+ # wtype types text directly into the focused window
58
+ wtype_bin = get_wtype_bin()
59
+ subprocess.run((wtype_bin, '--', text), check=True)
60
+ else:
61
+ # xdotool types text by simulating key events
62
+ xdotool_bin = get_xdotool_bin()
63
+ subprocess.run(
64
+ (xdotool_bin, 'type', '--clearmodifiers', '--delay', '0', '--', text),
65
+ check=True,
66
+ )
67
+
68
+
69
+ def record_and_inject(
70
+ *,
71
+ model: WhisperModel,
72
+ stop_event: threading.Event,
73
+ sample_rate: int,
74
+ ) -> None:
75
+ audio = record_until_event(stop_event=stop_event, sample_rate=sample_rate)
76
+ print('[cyan]Transcribing...[/cyan]')
77
+ text = transcribe(model, audio)
78
+ if text:
79
+ print(f'[green]Inserting:[/green] {text}')
80
+ inject_text(text)
81
+ else:
82
+ print('[dim]No speech detected.[/dim]')
83
+
84
+
85
+ class Recorder:
86
+ def __init__(self, *, model: WhisperModel, sample_rate: int):
87
+ self._model = model
88
+ self._sample_rate = sample_rate
89
+ self._stop_event: threading.Event | None = None
90
+ self._future: Future | None = None
91
+ self._executor = ThreadPoolExecutor(max_workers=1)
92
+
93
+ def start(self):
94
+ if self._future and not self._future.done():
95
+ return
96
+ self._stop_event = threading.Event()
97
+ self._future = self._executor.submit(
98
+ record_and_inject,
99
+ model=self._model,
100
+ stop_event=self._stop_event,
101
+ sample_rate=self._sample_rate,
102
+ )
103
+ print('[yellow]Recording...[/yellow]')
104
+
105
+ def stop(self):
106
+ if self._stop_event is not None:
107
+ self._stop_event.set()
@@ -0,0 +1,36 @@
1
+ import os
2
+ import unittest.util
3
+ from pathlib import Path
4
+
5
+ from bx_py_utils.test_utils.deny_requests import deny_any_real_request
6
+ from cli_base.cli_tools.verbosity import MAX_LOG_LEVEL, setup_logging
7
+ from rich import print
8
+ from typeguard import install_import_hook
9
+
10
+
11
+ # Check type annotations via typeguard in all tests:
12
+ install_import_hook(packages=('stt2desktop',))
13
+
14
+
15
+ def pre_configure_tests() -> None:
16
+ print(f'Configure unittests via "load_tests Protocol" from {Path(__file__).relative_to(Path.cwd())}')
17
+
18
+ # Hacky way to display more "assert"-Context in failing tests:
19
+ _MIN_MAX_DIFF = unittest.util._MAX_LENGTH - unittest.util._MIN_DIFF_LEN
20
+ unittest.util._MAX_LENGTH = int(os.environ.get('UNITTEST_MAX_LENGTH', 2000))
21
+ unittest.util._MIN_DIFF_LEN = unittest.util._MAX_LENGTH - _MIN_MAX_DIFF
22
+
23
+ # Deny any request via docket/urllib3 because tests they should mock all requests:
24
+ deny_any_real_request()
25
+
26
+ # Display DEBUG logs in tests:
27
+ setup_logging(verbosity=MAX_LOG_LEVEL)
28
+
29
+
30
+ def load_tests(loader, tests, pattern):
31
+ """
32
+ Use unittest "load_tests Protocol" as a hook to setup test environment before running tests.
33
+ https://docs.python.org/3/library/unittest.html#load-tests-protocol
34
+ """
35
+ pre_configure_tests()
36
+ return loader.discover(start_dir=Path(__file__).parent, pattern=pattern)
@@ -0,0 +1,10 @@
1
+ from bx_py_utils.test_utils.unittest_utils import BaseDocTests
2
+
3
+ import stt2desktop
4
+
5
+
6
+ class DocTests(BaseDocTests):
7
+ def test_doctests(self):
8
+ self.run_doctests(
9
+ modules=(stt2desktop,),
10
+ )
@@ -0,0 +1,49 @@
1
+ import subprocess
2
+ from unittest import TestCase
3
+
4
+ from bx_py_utils.path import assert_is_file
5
+ from cli_base.cli_tools.code_style import assert_code_style
6
+ from cli_base.cli_tools.subprocess_utils import ToolsExecutor
7
+ from manageprojects.test_utils.project_setup import (
8
+ check_editor_config,
9
+ get_py_max_line_length,
10
+ )
11
+ from packaging.version import Version
12
+
13
+ from stt2desktop import __version__
14
+ from stt2desktop.cli_dev import PACKAGE_ROOT
15
+
16
+
17
+ class ProjectSetupTestCase(TestCase):
18
+ def test_version(self):
19
+ self.assertIsNotNone(__version__)
20
+
21
+ version = Version(__version__) # Will raise InvalidVersion() if wrong formatted
22
+ self.assertEqual(str(version), __version__)
23
+
24
+ cli_bin = PACKAGE_ROOT / 'cli.py'
25
+ assert_is_file(cli_bin)
26
+
27
+ output = subprocess.check_output([cli_bin, 'version'], text=True)
28
+ self.assertIn(f'stt2desktop v{__version__}', output)
29
+
30
+ dev_cli_bin = PACKAGE_ROOT / 'dev-cli.py'
31
+ assert_is_file(dev_cli_bin)
32
+
33
+ output = subprocess.check_output([dev_cli_bin, 'version'], text=True)
34
+ self.assertIn(f'stt2desktop v{__version__}', output)
35
+
36
+ def test_code_style(self):
37
+ return_code = assert_code_style(package_root=PACKAGE_ROOT)
38
+ self.assertEqual(return_code, 0, 'Code style error, see output above!')
39
+
40
+ def test_check_editor_config(self):
41
+ check_editor_config(package_root=PACKAGE_ROOT)
42
+
43
+ max_line_length = get_py_max_line_length(package_root=PACKAGE_ROOT)
44
+ self.assertEqual(max_line_length, 119)
45
+
46
+ def test_pre_commit_hooks(self):
47
+ executor = ToolsExecutor(cwd=PACKAGE_ROOT)
48
+ for command in ('migrate-config', 'validate-config', 'validate-manifest'):
49
+ executor.verbose_check_call('pre-commit', command, exit_on_error=True)
@@ -0,0 +1,83 @@
1
+ from bx_py_utils.auto_doc import assert_readme_block
2
+ from bx_py_utils.path import assert_is_file
3
+ from cli_base.cli_tools.test_utils.assertion import assert_in
4
+ from cli_base.cli_tools.test_utils.rich_test_utils import NoColorEnvRich, invoke
5
+ from manageprojects.tests.base import BaseTestCase
6
+
7
+ from stt2desktop import constants
8
+ from stt2desktop.cli_dev import PACKAGE_ROOT
9
+ from stt2desktop.constants import DEFAULT_WHISPER_NUM_WORKERS
10
+
11
+
12
+ def assert_cli_help_in_readme(text_block: str, marker: str):
13
+ README_PATH = PACKAGE_ROOT / 'README.md'
14
+ assert_is_file(README_PATH)
15
+
16
+ text_block = text_block.replace(constants.CLI_EPILOG, '')
17
+ text_block = f'```\n{text_block.strip()}\n```'
18
+ assert_readme_block(
19
+ readme_path=README_PATH,
20
+ text_block=text_block,
21
+ start_marker_line=f'[comment]: <> (✂✂✂ auto generated {marker} start ✂✂✂)',
22
+ end_marker_line=f'[comment]: <> (✂✂✂ auto generated {marker} end ✂✂✂)',
23
+ )
24
+
25
+
26
+ class ReadmeTestCase(BaseTestCase):
27
+ def test_main_help(self):
28
+ with NoColorEnvRich():
29
+ stdout = invoke(
30
+ cli_bin=PACKAGE_ROOT / 'cli.py',
31
+ args=['--help'],
32
+ strip_line_prefix='usage: ',
33
+ )
34
+ assert_in(
35
+ content=stdout,
36
+ parts=(
37
+ 'usage: ./cli.py [-h]',
38
+ ' version ',
39
+ 'Print version and exit',
40
+ constants.CLI_EPILOG,
41
+ ),
42
+ )
43
+ assert_cli_help_in_readme(text_block=stdout, marker='main help')
44
+
45
+ def test_listen_help(self):
46
+ with NoColorEnvRich():
47
+ stdout = invoke(
48
+ cli_bin=PACKAGE_ROOT / 'cli.py',
49
+ args=['listen', '--help'],
50
+ strip_line_prefix='usage: ./cli.py listen ',
51
+ )
52
+ cpu_count_part = f'Defaults to CPU count. (default: {DEFAULT_WHISPER_NUM_WORKERS})'
53
+ assert_in(
54
+ content=stdout,
55
+ parts=(
56
+ 'usage: ./cli.py listen ',
57
+ ' --hotkey ',
58
+ ' --model ',
59
+ cpu_count_part,
60
+ ),
61
+ )
62
+ stdout = stdout.replace(cpu_count_part, 'Defaults to CPU count. (default:XY)').strip()
63
+ assert_cli_help_in_readme(text_block=stdout, marker='listen help')
64
+
65
+ def test_dev_help(self):
66
+ with NoColorEnvRich():
67
+ stdout = invoke(
68
+ cli_bin=PACKAGE_ROOT / 'dev-cli.py',
69
+ args=['--help'],
70
+ strip_line_prefix='usage: ',
71
+ )
72
+ assert_in(
73
+ content=stdout,
74
+ parts=(
75
+ 'usage: ./dev-cli.py [-h]',
76
+ ' lint ',
77
+ ' coverage ',
78
+ ' update-readme-history ',
79
+ ' publish ',
80
+ constants.CLI_EPILOG,
81
+ ),
82
+ )
83
+ assert_cli_help_in_readme(text_block=stdout, marker='dev help')
@@ -0,0 +1,9 @@
1
+ from unittest import TestCase
2
+
3
+ from cli_base.cli_tools.git_history import update_readme_history
4
+
5
+
6
+ class ReadmeHistoryTestCase(TestCase):
7
+ def test_readme_history(self):
8
+ with self.assertLogs():
9
+ update_readme_history(raise_update_error=True)
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: stt2desktop
3
+ Version: 0.0.1
4
+ Summary: Local speech-to-text for desktop using faster-whisper
5
+ Project-URL: Documentation, https://github.com/jedie/stt2desktop
6
+ Project-URL: Source, https://github.com/jedie/stt2desktop
7
+ Author-email: Jens Diemer <stt2desktop@jensdiemer.de>
8
+ License: GPL-3.0-or-later
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: bx-py-utils
11
+ Requires-Dist: cli-base-utilities>=0.27.1
12
+ Requires-Dist: faster-whisper
13
+ Requires-Dist: pynput
14
+ Requires-Dist: rich
15
+ Requires-Dist: sounddevice
16
+ Requires-Dist: tyro
17
+ Description-Content-Type: text/markdown
18
+
19
+ # stt2desktop
20
+
21
+ [![tests](https://github.com/jedie/stt2desktop/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/jedie/stt2desktop/actions/workflows/tests.yml)
22
+ [![codecov](https://codecov.io/github/jedie/stt2desktop/branch/main/graph/badge.svg)](https://app.codecov.io/github/jedie/stt2desktop)
23
+ [![stt2desktop @ PyPi](https://img.shields.io/pypi/v/stt2desktop?label=stt2desktop%20%40%20PyPi)](https://pypi.org/project/stt2desktop/)
24
+ [![Python Versions](https://img.shields.io/pypi/pyversions/stt2desktop)](https://github.com/jedie/stt2desktop/blob/main/pyproject.toml)
25
+ [![License GPL-3.0-or-later](https://img.shields.io/pypi/l/stt2desktop)](https://github.com/jedie/stt2desktop/blob/main/LICENSE)
26
+
27
+ Local speech-to-text for desktop using [faster-whisper](https://github.com/SYSTRAN/faster-whisper).
28
+
29
+ Let's you dictate text into any application without sending audio to any cloud services.
30
+ Everything runs locally on your machine — no internet connection required after the initial model was download.
31
+
32
+ ## How it works
33
+
34
+ 1. Run `./cli.py listen` — the Whisper model is loaded into memory (downloaded on first run).
35
+ 2. Hold **Scroll Lock** to record from your microphone.
36
+ 3. Release **Scroll Lock** — the audio is transcribed locally by faster-whisper (CPU, `int8`).
37
+ 4. The text is typed into the focused window via `wtype` (Wayland) or `xdotool` (X11).
38
+
39
+ ## Setup
40
+
41
+ Requirements: Python 3.12+, [`uv`](https://github.com/astral-sh/uv), a working microphone, and either `wtype` (Wayland) or `xdotool` (X11):
42
+
43
+ ```bash
44
+ sudo apt install wtype # Wayland
45
+ sudo apt install xdotool # X11
46
+ ```
47
+
48
+ Then run:
49
+
50
+ ```bash
51
+ ./cli.py listen
52
+ ```
53
+
54
+ [comment]: <> (✂✂✂ auto generated listen help start ✂✂✂)
55
+ ```
56
+ usage: ./cli.py listen [-h] [LISTEN OPTIONS]
57
+
58
+ Start the STT listener. Hold the hotkey to record, release to transcribe and insert.
59
+
60
+ ╭─ options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
61
+ │ -h, --help show this help message and exit │
62
+ │ -v, --verbosity Verbosity level; e.g.: -v, -vv, -vvv, etc. (repeatable) │
63
+ │ --model {tiny_en,tiny,base_en,base,small_en,small,medium_en,medium,large_v1,large_v2,large_v3,large,distil_large_v2, │
64
+ │ distil_medium_en,distil_small_en,distil_large_v3,distil_large_v3_5,large_v3_turbo,turbo} │
65
+ │ Whisper model to use for transcription. (default: small) │
66
+ │ --hotkey {alt,alt_l,alt_r,alt_gr,backspace,caps_lock,cmd,cmd_l,cmd_r,ctrl,ctrl_l,ctrl_r,delete,down,end,enter,esc, │
67
+ │ f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13,f14,f15,f16,f17,f18,f19,f20,home,left,page_down,page_up,right,shift, │
68
+ │ shift_l,shift_r,space,tab,up,media_play_pause,media_volume_mute,media_volume_down,media_volume_up,media_previous, │
69
+ │ media_next,insert,menu,num_lock,pause,print_screen,scroll_lock} │
70
+ │ Key to hold for recording. Release to transcribe and insert text. (default: scroll_lock) │
71
+ │ --sample-rate INT Audio sample rate in Hz. Whisper expects 16000. (default: 16000) │
72
+ │ --device STR Device to run inference on, e.g. cpu or cuda. (default: auto) │
73
+ │ --compute-type STR Quantization type, e.g. int8, float16, float32. (default: int8) │
74
+ │ --num-workers INT Number of parallel transcription workers. Defaults to CPU count. (default:XY) │
75
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
76
+ ```
77
+ [comment]: <> (✂✂✂ auto generated listen help end ✂✂✂)
78
+
79
+ ### Available models
80
+
81
+ | Model | Size | Speed | Accuracy |
82
+ |-------|------|-------|----------|
83
+ | `tiny` | ~75 MB | fastest | lowest |
84
+ | `base` | ~145 MB | fast | good (default) |
85
+ | `small` | ~460 MB | slower | better |
86
+ | `medium` | ~1.5 GB | slow | high |
87
+
88
+ Larger models produce more accurate transcriptions but take longer to process. For most use cases `base` or `small` is a good starting point.
89
+
90
+ ## CLI
91
+
92
+ [comment]: <> (✂✂✂ auto generated main help start ✂✂✂)
93
+ ```
94
+ usage: ./cli.py [-h] {listen,version}
95
+
96
+
97
+
98
+ ╭─ options ─────────────────────────────────────────────────────────────────────────────────────────╮
99
+ │ -h, --help show this help message and exit │
100
+ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯
101
+ ╭─ subcommands ─────────────────────────────────────────────────────────────────────────────────────╮
102
+ │ (required) │
103
+ │ • listen Start the STT listener. Hold the hotkey to record, release to transcribe and insert. │
104
+ │ • version Print version and exit │
105
+ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯
106
+ ```
107
+ [comment]: <> (✂✂✂ auto generated main help end ✂✂✂)
108
+
109
+
110
+ ## start development
111
+
112
+ At least `uv` is needed. Install e.g.: via pipx:
113
+ ```bash
114
+ apt-get install pipx
115
+ pipx install uv
116
+ ```
117
+
118
+ Clone the project and just start the CLI help commands.
119
+ A virtual environment will be created/updated automatically.
120
+
121
+ ```bash
122
+ ~$ git clone https://github.com/jedie/stt2desktop.git
123
+ ~$ cd stt2desktop
124
+ ~/stt2desktop$ ./cli.py --help
125
+ ~/stt2desktop$ ./dev-cli.py --help
126
+ ```
127
+
128
+ [comment]: <> (✂✂✂ auto generated dev help start ✂✂✂)
129
+ ```
130
+ usage: ./dev-cli.py [-h] {coverage,install,lint,mypy,nox,pip-audit,publish,test,update,update-readme-history,update-test-snapshot-files,version}
131
+
132
+
133
+
134
+ ╭─ options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
135
+ │ -h, --help show this help message and exit │
136
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
137
+ ╭─ subcommands ────────────────────────────────────────────────────────────────────────────────────────────────────────╮
138
+ │ (required) │
139
+ │ • coverage Run tests and show coverage report. │
140
+ │ • install Install requirements and 'stt2desktop' via pip as editable. │
141
+ │ • lint Check/fix code style by run: "ruff check --fix" │
142
+ │ • mypy Run Mypy (configured in pyproject.toml) │
143
+ │ • nox Run nox │
144
+ │ • pip-audit │
145
+ │ Run pip-audit check against current requirements files │
146
+ │ • publish Build and upload this project to PyPi │
147
+ │ • test Run unittests │
148
+ │ • update Update dependencies (uv.lock) and git pre-commit hooks │
149
+ │ • update-readme-history │
150
+ │ Update project history base on git commits/tags in README.md Will be exited with 1 if the README.md │
151
+ │ was updated otherwise with 0. │
152
+ │ │
153
+ │ Also, callable via e.g.: │
154
+ │ python -m cli_base update-readme-history -v │
155
+ │ • update-test-snapshot-files │
156
+ │ Update all test snapshot files (by remove and recreate all snapshot files) │
157
+ │ • version Print version and exit │
158
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
159
+ ```
160
+ [comment]: <> (✂✂✂ auto generated dev help end ✂✂✂)
161
+
162
+
163
+ ## History
164
+
165
+ [comment]: <> (✂✂✂ auto generated history start ✂✂✂)
166
+
167
+ * [**dev**](https://github.com/jedie/stt2desktop/compare/b407f8f...main)
168
+ * 2026-03-26 - Add POC
169
+ * 2026-03-26 - init
170
+
171
+ [comment]: <> (✂✂✂ auto generated history end ✂✂✂)
@@ -0,0 +1,22 @@
1
+ stt2desktop/__init__.py,sha256=D2_c_60RhAe2VD2IrFDTBzVzJgqchFg9lAevaag8f-s,217
2
+ stt2desktop/__main__.py,sha256=qoH1ZrA02HBmz5E16WpDaGfi3jUnm4s8tkyEbfaRjU0,154
3
+ stt2desktop/constants.py,sha256=nnjqZZ_Ym2oM_rqaxSxWPJum3nxOxYUT-U_L5pSbEfk,769
4
+ stt2desktop/get_bin.py,sha256=X-gV5BYOqlDdmHh--VNZsYS2U4rSyJNxHiKtwTQuDQo,831
5
+ stt2desktop/stt.py,sha256=25rjDtxWMknrn5lQ3ByV3v8JsrCxAUH6tR7KN9tHOfY,3508
6
+ stt2desktop/cli_app/__init__.py,sha256=DmwDpQ2ga-FbkOLtPCyllv6ID-_GelIpV_WTNcYK1s0,951
7
+ stt2desktop/cli_app/listen.py,sha256=siFfL17pt40oJSNVZWStqfGzG1WbLNMnGuJUeS_REhU,2618
8
+ stt2desktop/cli_dev/__init__.py,sha256=cCFldU21Py496ZIDxPWEvjCNKZT0x92sDJNMqgmGcII,1889
9
+ stt2desktop/cli_dev/__main__.py,sha256=e36uw5SUShOGKBRaC21xB5Rw2AmTQ-uKTDmCCnXjwx4,162
10
+ stt2desktop/cli_dev/code_style.py,sha256=BAXhqvmVPuDqC8D0AjIN0EJHPbT3GdzbIqePaEMHc6Y,386
11
+ stt2desktop/cli_dev/packaging.py,sha256=WvuV-kNHkoOnZSvwdqatNwMn8GoTcPsH9BpdxvpoWus,1814
12
+ stt2desktop/cli_dev/testing.py,sha256=yiXBX4LzVU6Eh12rj2ENJqt6V57l3XKB9D-gCMV2Kbw,1384
13
+ stt2desktop/cli_dev/update_readme_history.py,sha256=12swOa8O7y5Tkuw4pLN_cT_4zTTfSHe4VYlwHdnl_5k,896
14
+ stt2desktop/tests/__init__.py,sha256=-uUIzVrd-btVeP7UME5oDmUpNNVA9tr1ufaKhBUlRR4,1334
15
+ stt2desktop/tests/test_doctests.py,sha256=EGemZFe1HDN2HCBdxeIC_EDjA0OMVV0hfH3RoEbj-t4,217
16
+ stt2desktop/tests/test_project_setup.py,sha256=zkmzVwDDi6NW-ndSpvdWpYiwx9PLuuz8xEq-d9owmmM,1788
17
+ stt2desktop/tests/test_readme.py,sha256=R3_tXsr2CSzAnJomJ0sWPGfU9BV2R-tyC5ErerJXFtU,2910
18
+ stt2desktop/tests/test_readme_history.py,sha256=PbxF_yRspyRwkVCQosZXevIHrsla3WFBTUVeLI0bb-s,263
19
+ stt2desktop-0.0.1.dist-info/METADATA,sha256=2Dj2dEDxCtEsCqVDfCbiD3TnZzkEGNqquSaC89ju9l8,12134
20
+ stt2desktop-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
21
+ stt2desktop-0.0.1.dist-info/entry_points.txt,sha256=ezr7yveS5bFKyhDtKg_Xq2jd6_e3cfHm9soM8rnS_II,58
22
+ stt2desktop-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stt2desktop = stt2desktop.__main__:main