clicue 0.1.2__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,15 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Models
13
+ model/
14
+ model-full/
15
+ model*/
clicue-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: clicue
3
+ Version: 0.1.2
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: rapidfuzz
7
+ Requires-Dist: rich
8
+ Requires-Dist: sounddevice
9
+ Requires-Dist: vosk
clicue-0.1.2/README.md ADDED
File without changes
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "clicue"
3
+ version = "0.1.2"
4
+
5
+
6
+ description = "Add your description here"
7
+ readme = "README.md"
8
+ requires-python = ">=3.12"
9
+ dependencies = ["rapidfuzz", "vosk", "sounddevice", "rich"]
10
+
11
+ [project.scripts]
12
+ clicue = "clicue.main:main"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.sdist]
19
+ only-include = ["src/clicue", "README.md", "pyproject.toml"]
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/clicue"]
23
+
File without changes
@@ -0,0 +1,73 @@
1
+ import time
2
+ import sys
3
+ from rapidfuzz import fuzz
4
+
5
+ class Aligner:
6
+ def __init__(
7
+ self,
8
+ script_words: list[str],
9
+ max_lookahead: int = 20,
10
+ threshold: float = 70.0,
11
+ locality_penalty: float = 1.5,
12
+ window_size: int = None,
13
+ perf_log: bool = False
14
+ ):
15
+ self.script_words = script_words
16
+ self.lower_words = [w.lower() for w in script_words]
17
+ self.current_index = 0
18
+ self.max_lookahead = window_size if window_size is not None else max_lookahead
19
+ self.threshold = threshold
20
+ self.locality_penalty = locality_penalty
21
+ self.perf_log = perf_log
22
+ self.call_count = 0
23
+ self.total_time_ms = 0.0
24
+
25
+ def advance(self, stt_text: str) -> int:
26
+ if self.current_index >= len(self.script_words):
27
+ return self.current_index
28
+
29
+ start_time = time.perf_counter()
30
+
31
+ stt_words = stt_text.lower().split()
32
+ if not stt_words:
33
+ return self.current_index
34
+
35
+ clean_stt_text = " ".join(stt_words)
36
+ match_len = len(stt_words)
37
+
38
+ # Restrict lookahead based on STT utterance length
39
+ effective_lookahead = min(self.max_lookahead, max(6, match_len * 3))
40
+ end_index = min(self.current_index + effective_lookahead, len(self.script_words))
41
+
42
+ best_score = -100.0
43
+ best_next_idx = self.current_index
44
+ best_ratio = 0.0
45
+
46
+ for i in range(self.current_index, end_index):
47
+ # Form phrase using pre-lowercased words to eliminate string allocation overhead
48
+ script_phrase = " ".join(self.lower_words[i:i + match_len])
49
+
50
+ ratio = fuzz.ratio(clean_stt_text, script_phrase)
51
+
52
+ # Apply locality penalty for jumping forward
53
+ distance = i - self.current_index
54
+ penalized_score = ratio - (distance * self.locality_penalty)
55
+
56
+ if penalized_score > best_score:
57
+ best_score = penalized_score
58
+ best_ratio = ratio
59
+ best_next_idx = i + match_len
60
+
61
+ # Check threshold
62
+ if best_ratio >= self.threshold or best_score >= (self.threshold - 10.0):
63
+ self.current_index = min(best_next_idx, len(self.script_words))
64
+
65
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
66
+ self.call_count += 1
67
+ self.total_time_ms += elapsed_ms
68
+
69
+ if self.perf_log and self.call_count % 10 == 0:
70
+ avg_ms = self.total_time_ms / self.call_count
71
+ print(f"[PERF] Align call #{self.call_count}: {elapsed_ms:.2f}ms (avg: {avg_ms:.2f}ms) | STT: '{clean_stt_text}' -> Index {self.current_index}", file=sys.stderr)
72
+
73
+ return self.current_index
@@ -0,0 +1,56 @@
1
+ import os
2
+ import sys
3
+ if sys.version_info >= (3, 11):
4
+ import tomllib
5
+ else:
6
+ import tomli as tomllib
7
+
8
+ DEFAULT_CONFIG = {
9
+ "scroller": {
10
+ "window_size": 38,
11
+ "past_size": 9,
12
+ },
13
+ "aligner": {
14
+ "max_lookahead": 20,
15
+ "threshold": 70.0,
16
+ "locality_penalty": 1.5,
17
+ },
18
+ "audio": {
19
+ "sample_rate": 16000,
20
+ "model_path": "model",
21
+ },
22
+ "debug": {
23
+ "perf_log": False,
24
+ }
25
+ }
26
+
27
+ def load_config(config_path: str = None) -> dict:
28
+ """
29
+ Loads configuration from `.clicue.toml` or `~/.config/clicue/config.toml`.
30
+ Falls back to DEFAULT_CONFIG.
31
+ """
32
+ cfg = {k: dict(v) for k, v in DEFAULT_CONFIG.items()}
33
+
34
+ paths_to_check = []
35
+ if config_path:
36
+ paths_to_check.append(config_path)
37
+ paths_to_check.extend([
38
+ ".clicue.toml",
39
+ os.path.expanduser("~/.config/clicue/config.toml"),
40
+ ])
41
+
42
+ for path in paths_to_check:
43
+ if os.path.isfile(path):
44
+ try:
45
+ with open(path, "rb") as f:
46
+ file_cfg = tomllib.load(f)
47
+ for sec, vals in file_cfg.items():
48
+ if sec in cfg and isinstance(vals, dict):
49
+ cfg[sec].update(vals)
50
+ else:
51
+ cfg[sec] = vals
52
+ break
53
+ except Exception as e:
54
+ print(f"Warning: Failed to parse config file '{path}': {e}", file=sys.stderr)
55
+
56
+ return cfg
@@ -0,0 +1,91 @@
1
+ import re
2
+
3
+ HEADER_KEYWORDS = {"title:", "author:", "authors:", "draft date:", "date:", "contact:", "copyright:", "notes:"}
4
+
5
+ class ParsedScript:
6
+ def __init__(self, words: list[str], cues: list[str], para_starts: list[bool]):
7
+ self.words = words
8
+ self.cues = cues
9
+ self.para_starts = para_starts
10
+
11
+ def __len__(self):
12
+ return len(self.words)
13
+
14
+ def is_header_line(line: str) -> bool:
15
+ low = line.strip().lower()
16
+ return any(low.startswith(k) for k in HEADER_KEYWORDS)
17
+
18
+ def is_scene_heading(line: str) -> bool:
19
+ stripped = line.strip()
20
+ if re.match(r"^(INT\.|EXT\.|EST\.|INT\./EXT\.|EXT\./INT\.|\.)", stripped, re.IGNORECASE):
21
+ return True
22
+ return False
23
+
24
+ def is_character_name(line: str) -> bool:
25
+ stripped = line.strip()
26
+ if stripped.isupper() and 0 < len(stripped) < 30 and not is_scene_heading(stripped):
27
+ return True
28
+ return False
29
+
30
+ def parse_fountain(content: str) -> ParsedScript:
31
+ """
32
+ Parses Fountain or markdown script content.
33
+ Extracts spoken dialogue words while tracking active stage cues ([Screen Recording...])
34
+ and paragraph boundaries for teleprompter formatting.
35
+ """
36
+ lines = content.splitlines()
37
+
38
+ words = []
39
+ cues = []
40
+ para_starts = []
41
+
42
+ current_cue = ""
43
+ in_header = True
44
+ next_is_para_start = True
45
+
46
+ for line in lines:
47
+ stripped = line.strip()
48
+
49
+ if not stripped:
50
+ next_is_para_start = True
51
+ continue
52
+
53
+ # Check for metadata header
54
+ if in_header:
55
+ if is_header_line(line):
56
+ continue
57
+ else:
58
+ in_header = False
59
+
60
+ # Check for stage direction / bracketed cues [Screen Recording: ...]
61
+ bracket_cues = re.findall(r"\[(.*?)\]", line)
62
+ if bracket_cues:
63
+ raw_cue = bracket_cues[-1].strip()
64
+ # Strip redundant prefixes like "Screen Recording:", "Visual:", etc.
65
+ clean_cue = re.sub(r"^(Screen Recording|Visual|Audio|Note|Action):\s*", "", raw_cue, flags=re.IGNORECASE)
66
+ current_cue = clean_cue
67
+
68
+ if is_scene_heading(line):
69
+ continue
70
+
71
+ if is_character_name(line):
72
+ continue
73
+
74
+ # Clean spoken text by removing [...] and (...)
75
+ line_clean = re.sub(r"\[.*?\]", "", line)
76
+ line_clean = re.sub(r"\(.*?\)", "", line_clean).strip()
77
+
78
+ if line_clean:
79
+ line_words = line_clean.split()
80
+ for i, w in enumerate(line_words):
81
+ words.append(w)
82
+ cues.append(current_cue)
83
+ # First word of a line/paragraph gets the paragraph start flag
84
+ para_starts.append(next_is_para_start and (i == 0))
85
+
86
+ next_is_para_start = False
87
+
88
+ return ParsedScript(words=words, cues=cues, para_starts=para_starts)
89
+
90
+ def parse_fountain_words(content: str) -> list[str]:
91
+ return parse_fountain(content).words
@@ -0,0 +1,12 @@
1
+ from clicue.stt.vosk_engine import VoskSTTListener
2
+
3
+ # Backward compatibility alias
4
+ STTListener = VoskSTTListener
5
+
6
+ if __name__ == "__main__":
7
+ listener = STTListener()
8
+ try:
9
+ for text in listener.listen():
10
+ print(f"Recognized: {text}")
11
+ except KeyboardInterrupt:
12
+ print("\nStopped listening.")
@@ -0,0 +1,224 @@
1
+ import argparse
2
+ import sys
3
+ from rich.live import Live
4
+
5
+ from clicue.aligner import Aligner
6
+ from clicue.scroller import TUIScroller
7
+ from clicue.stt import get_stt_listener
8
+ from clicue.fountain import parse_fountain, ParsedScript
9
+ from clicue.config import load_config
10
+
11
+
12
+ def parse_script_from_file(file_obj, raw=False) -> ParsedScript:
13
+ content = file_obj.read()
14
+ if raw:
15
+ words = content.split()
16
+ return ParsedScript(
17
+ words=words,
18
+ cues=[""] * len(words),
19
+ para_starts=[i == 0 for i in range(len(words))]
20
+ )
21
+ return parse_fountain(content)
22
+
23
+ def parse_words_from_file(file_obj, raw=False):
24
+ return parse_script_from_file(file_obj, raw).words
25
+
26
+ import select
27
+ import termios
28
+ import tty
29
+
30
+ class KeyboardListener:
31
+ def __enter__(self):
32
+ self.old_settings = None
33
+ if sys.stdin.isatty():
34
+ try:
35
+ self.old_settings = termios.tcgetattr(sys.stdin)
36
+ tty.setcbreak(sys.stdin.fileno())
37
+ except Exception:
38
+ pass
39
+ return self
40
+
41
+ def __exit__(self, type, value, traceback):
42
+ if self.old_settings and sys.stdin.isatty():
43
+ try:
44
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.old_settings)
45
+ except Exception:
46
+ pass
47
+
48
+ def get_key(self) -> str | None:
49
+ if not sys.stdin.isatty():
50
+ return None
51
+ dr, _, _ = select.select([sys.stdin], [], [], 0)
52
+ if dr:
53
+ ch = sys.stdin.read(1)
54
+ if ch == '\x1b':
55
+ dr2, _, _ = select.select([sys.stdin], [], [], 0.01)
56
+ if dr2:
57
+ ch2 = sys.stdin.read(1)
58
+ if ch2 == '[':
59
+ dr3, _, _ = select.select([sys.stdin], [], [], 0.01)
60
+ if dr3:
61
+ ch3 = sys.stdin.read(1)
62
+ if ch3 == 'D':
63
+ return 'LEFT'
64
+ elif ch3 == 'C':
65
+ return 'RIGHT'
66
+ return ch
67
+ return None
68
+
69
+ def main(args=None):
70
+ parser = argparse.ArgumentParser(description="clicue - teleprompter script scroller")
71
+ parser.add_argument(
72
+ "script",
73
+ nargs="?",
74
+ type=argparse.FileType("r"),
75
+ default=sys.stdin,
76
+ help="Path to the script file (or '-' for stdin). If omitted, reads from stdin.",
77
+ )
78
+ parser.add_argument(
79
+ "--config",
80
+ default=None,
81
+ help="Path to a TOML configuration file."
82
+ )
83
+ parser.add_argument(
84
+ "--window-size",
85
+ type=int,
86
+ default=None,
87
+ help="Number of upcoming words to display in look-ahead."
88
+ )
89
+ parser.add_argument(
90
+ "--past-size",
91
+ type=int,
92
+ default=None,
93
+ help="Number of past words to display in look-behind."
94
+ )
95
+ parser.add_argument(
96
+ "--max-lookahead",
97
+ type=int,
98
+ default=None,
99
+ help="Maximum word distance to search ahead for alignment."
100
+ )
101
+ parser.add_argument(
102
+ "--threshold",
103
+ type=float,
104
+ default=None,
105
+ help="Fuzzy match confidence threshold (0.0 - 100.0)."
106
+ )
107
+ parser.add_argument(
108
+ "--perf-log",
109
+ action="store_true",
110
+ help="Log performance metrics to stderr."
111
+ )
112
+ parser.add_argument(
113
+ "--engine",
114
+ default="vosk",
115
+ help="STT engine plugin to use (default: vosk)."
116
+ )
117
+ parser.add_argument(
118
+ "--model-path",
119
+ default=None,
120
+ help="Path to the model directory."
121
+ )
122
+ parser.add_argument(
123
+ "--device",
124
+ default=None,
125
+ help="Audio input device ID or name."
126
+ )
127
+ parser.add_argument(
128
+ "--audio-file",
129
+ default=None,
130
+ help="Path to a pre-recorded WAV audio file to use instead of live microphone input."
131
+ )
132
+ parser.add_argument(
133
+ "--raw",
134
+ action="store_true",
135
+ help="Do not parse Fountain syntax; read all text literally."
136
+ )
137
+ parser.add_argument(
138
+ "--list-devices",
139
+ action="store_true",
140
+ help="List available audio input devices and exit."
141
+ )
142
+
143
+ parsed_args = parser.parse_args(args)
144
+
145
+ if parsed_args.list_devices:
146
+ import sounddevice as sd
147
+ print("Available Audio Devices:")
148
+ print(sd.query_devices())
149
+ return
150
+
151
+ # Load TOML config and merge with CLI arguments
152
+ cfg = load_config(parsed_args.config)
153
+
154
+ engine_name = parsed_args.engine or cfg.get("audio", {}).get("engine", "vosk")
155
+ window_size = parsed_args.window_size or cfg["scroller"]["window_size"]
156
+ past_size = parsed_args.past_size or cfg["scroller"]["past_size"]
157
+ max_lookahead = parsed_args.max_lookahead or cfg["aligner"]["max_lookahead"]
158
+ threshold = parsed_args.threshold or cfg["aligner"]["threshold"]
159
+ locality_penalty = cfg["aligner"].get("locality_penalty", 1.5)
160
+ model_path = parsed_args.model_path or cfg["audio"]["model_path"]
161
+ perf_log = parsed_args.perf_log or cfg["debug"]["perf_log"]
162
+
163
+ script = parse_script_from_file(parsed_args.script, raw=parsed_args.raw)
164
+
165
+ if not script.words:
166
+ print("Script is empty.")
167
+ return 0
168
+
169
+ device_param = parsed_args.device
170
+ if device_param is not None and device_param.isdigit():
171
+ device_param = int(device_param)
172
+
173
+ aligner = Aligner(
174
+ script.words,
175
+ max_lookahead=max_lookahead,
176
+ threshold=threshold,
177
+ locality_penalty=locality_penalty,
178
+ perf_log=perf_log
179
+ )
180
+ scroller = TUIScroller(script, window_size=window_size, past_size=past_size)
181
+ listener = get_stt_listener(engine_name=engine_name, model_path=model_path, device=device_param)
182
+
183
+ audio_stream = listener.listen_file(parsed_args.audio_file) if parsed_args.audio_file else listener.listen()
184
+
185
+ current_idx = 0
186
+ is_paused = False
187
+
188
+ try:
189
+ with KeyboardListener() as kbd:
190
+ with Live(scroller.render(0, is_paused=False), refresh_per_second=15, auto_refresh=False, screen=True) as live:
191
+ for text in audio_stream:
192
+ # Check non-blocking hotkey input
193
+ key = kbd.get_key()
194
+ if key in (' ', 'p'):
195
+ is_paused = not is_paused
196
+ live.update(scroller.render(current_idx, is_paused=is_paused), refresh=True)
197
+ elif key in ('LEFT', 'b'):
198
+ current_idx = max(0, current_idx - 5)
199
+ aligner.current_index = current_idx
200
+ live.update(scroller.render(current_idx, is_paused=is_paused), refresh=True)
201
+ elif key in ('RIGHT', 'f'):
202
+ current_idx = min(len(script.words) - 1, current_idx + 5)
203
+ aligner.current_index = current_idx
204
+ live.update(scroller.render(current_idx, is_paused=is_paused), refresh=True)
205
+ elif key == 'q':
206
+ break
207
+
208
+ # Advance cursor with STT only if not paused
209
+ if not is_paused:
210
+ new_idx = aligner.advance(text)
211
+ if new_idx != current_idx:
212
+ current_idx = new_idx
213
+ live.update(scroller.render(current_idx, is_paused=is_paused), refresh=True)
214
+
215
+ if current_idx >= len(script.words):
216
+ break
217
+ except KeyboardInterrupt:
218
+ pass
219
+
220
+ return 0
221
+
222
+ if __name__ == "__main__":
223
+ main()
224
+
@@ -0,0 +1,110 @@
1
+ from rich.console import Console
2
+ from rich.text import Text
3
+ from rich.console import Group
4
+ from rich.padding import Padding
5
+ from clicue.fountain import ParsedScript
6
+
7
+ class TUIScroller:
8
+ def __init__(self, script: ParsedScript, window_size: int = 38, past_size: int = 9):
9
+ if isinstance(script, ParsedScript):
10
+ self.script = script
11
+ else:
12
+ self.script = ParsedScript(
13
+ words=script,
14
+ cues=[""] * len(script),
15
+ para_starts=[i == 0 for i in range(len(script))]
16
+ )
17
+
18
+ self.window_size = window_size
19
+ self.past_size = past_size
20
+ self.console = Console()
21
+
22
+ # Precompute paragraph start word indices
23
+ self.para_starts_indices = [
24
+ i for i, is_start in enumerate(self.script.para_starts) if is_start
25
+ ]
26
+ if not self.para_starts_indices:
27
+ self.para_starts_indices = [0]
28
+
29
+ def _get_current_para_index(self, current_index: int) -> int:
30
+ for p_idx in range(len(self.para_starts_indices) - 1, -1, -1):
31
+ if self.para_starts_indices[p_idx] <= current_index:
32
+ return p_idx
33
+ return 0
34
+
35
+ def render(self, current_index: int, is_paused: bool = False) -> Group:
36
+ """
37
+ Renders the TUI layout with player controls status bar:
38
+ - Top Header: Player state ([TRACKING ▶] or [PAUSED ⏸]), stage cue, and hotkey guide.
39
+ - Text Body: Anchored at paragraph boundaries for zero-reflow teleprompter viewing.
40
+ """
41
+ current_index = max(0, min(current_index, len(self.script) - 1)) if len(self.script) > 0 else 0
42
+
43
+ # 1. Player Controls & Stage Cue Header
44
+ header_text = Text()
45
+
46
+ if is_paused:
47
+ header_text.append("[PAUSED ⏸] ", style="bold black on yellow")
48
+ else:
49
+ header_text.append("[TRACKING ▶] ", style="bold white on green")
50
+
51
+ active_cue = self.script.cues[current_index] if len(self.script.cues) > current_index else ""
52
+ if active_cue:
53
+ header_text.append(" 🎬 ", style="yellow")
54
+ header_text.append(active_cue, style="bold yellow")
55
+ else:
56
+ header_text.append(" 🎬 ", style="dim yellow")
57
+ header_text.append("(no active cue)", style="dim yellow")
58
+
59
+ # Hotkey Help Bar
60
+ footer_help = Text()
61
+ footer_help.append("[Space]: Pause/Resume | [← / b]: Rewind 5w | [→ / f]: Skip 5w | [q]: Quit", style="dim cyan")
62
+
63
+ # 2. Text Body with Paragraph-Anchored Stationary Layout
64
+ p_idx = self._get_current_para_index(current_index)
65
+ prev_p_idx = max(0, p_idx - 1)
66
+ start_index = self.para_starts_indices[prev_p_idx]
67
+
68
+ end_index = min(
69
+ start_index + max(self.window_size + self.past_size, current_index - start_index + self.window_size),
70
+ len(self.script.words)
71
+ )
72
+
73
+ body_text = Text()
74
+
75
+ for idx in range(start_index, end_index):
76
+ word = self.script.words[idx]
77
+
78
+ if idx > start_index and self.script.para_starts[idx]:
79
+ body_text.append("\n\n")
80
+
81
+ if idx < current_index:
82
+ body_text.append(word, style="dim white")
83
+ elif idx == current_index:
84
+ body_text.append(word, style="bold bright_green")
85
+ else:
86
+ body_text.append(word, style="white")
87
+
88
+ body_text.append(" ")
89
+
90
+ return Group(
91
+ Padding(header_text, (0, 0, 0, 0)),
92
+ Padding(footer_help, (0, 0, 1, 0)),
93
+ Padding(body_text, (0, 0, 0, 0))
94
+ )
95
+
96
+ def render_text(self, current_index: int, is_paused: bool = False):
97
+ return self.render(current_index, is_paused=is_paused)
98
+
99
+ def display(self, current_index: int, is_paused: bool = False):
100
+ self.console.clear()
101
+ self.console.print(self.render(current_index, is_paused=is_paused))
102
+
103
+ if __name__ == "__main__":
104
+ import time
105
+ words = ["Hello", "world", "this", "is", "a", "test"]
106
+ script = ParsedScript(words=words, cues=["Screen recording"]*6, para_starts=[True, False, False, True, False, False])
107
+ scroller = TUIScroller(script)
108
+ for i in range(len(words)):
109
+ scroller.display(i)
110
+ time.sleep(0.3)
@@ -0,0 +1,15 @@
1
+ from clicue.stt.base import BaseSTTListener
2
+ from clicue.stt.vosk_engine import VoskSTTListener
3
+
4
+ STT_ENGINES = {
5
+ "vosk": VoskSTTListener,
6
+ }
7
+
8
+ def get_stt_listener(engine_name: str = "vosk", **kwargs) -> BaseSTTListener:
9
+ """
10
+ Factory function to instantiate STT listener plugins by engine name.
11
+ """
12
+ engine_name = engine_name.lower().strip()
13
+ if engine_name not in STT_ENGINES:
14
+ raise ValueError(f"Unknown STT engine '{engine_name}'. Available engines: {list(STT_ENGINES.keys())}")
15
+ return STT_ENGINES[engine_name](**kwargs)
@@ -0,0 +1,20 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Iterator
3
+
4
+ class BaseSTTListener(ABC):
5
+ """
6
+ Abstract base class for all STT listener plugins in clicue.
7
+ """
8
+
9
+ @abstractmethod
10
+ def listen(self, device=None) -> Iterator[str]:
11
+ """
12
+ Listens to continuous live audio input and yields recognized text strings.
13
+ """
14
+ pass
15
+
16
+ def listen_file(self, audio_file_path: str) -> Iterator[str]:
17
+ """
18
+ Optional: Processes a WAV audio file and yields recognized text strings.
19
+ """
20
+ raise NotImplementedError("File streaming is not implemented for this STT engine.")
@@ -0,0 +1,91 @@
1
+ import queue
2
+ import sys
3
+ import json
4
+ import sounddevice as sd
5
+ from vosk import Model, KaldiRecognizer, SetLogLevel
6
+
7
+ from clicue.stt.base import BaseSTTListener
8
+
9
+ # Suppress Vosk C++ logs
10
+ SetLogLevel(-1)
11
+
12
+ class VoskSTTListener(BaseSTTListener):
13
+ def __init__(self, model_path="model", sample_rate=16000, device=None):
14
+ self.sample_rate = sample_rate
15
+ self.device = device
16
+ self.model_path = model_path
17
+ try:
18
+ self.model = Model(model_path)
19
+ except Exception as e:
20
+ print(f"Error loading Vosk model from '{model_path}': {e}", file=sys.stderr)
21
+ print("Please download a model from https://alphacephei.com/vosk/models and extract it to that path.", file=sys.stderr)
22
+ sys.exit(1)
23
+
24
+ self.recognizer = KaldiRecognizer(self.model, self.sample_rate)
25
+ self.q = queue.Queue()
26
+
27
+ def _audio_callback(self, indata, frames, time, status):
28
+ if status:
29
+ print(status, file=sys.stderr)
30
+ self.q.put(bytes(indata))
31
+
32
+ def listen_file(self, audio_file_path: str):
33
+ import wave
34
+ import time
35
+
36
+ try:
37
+ wf = wave.open(audio_file_path, "rb")
38
+ except Exception as e:
39
+ print(f"Error opening audio file '{audio_file_path}': {e}", file=sys.stderr)
40
+ return
41
+
42
+ rec = KaldiRecognizer(self.model, wf.getframerate())
43
+ chunk_size = 4000
44
+
45
+ while True:
46
+ data = wf.readframes(chunk_size)
47
+ if len(data) == 0:
48
+ break
49
+ time.sleep(0.1)
50
+ if rec.AcceptWaveform(data):
51
+ result = json.loads(rec.Result())
52
+ text = result.get("text", "")
53
+ if text:
54
+ yield text
55
+ else:
56
+ result = json.loads(rec.PartialResult())
57
+ text = result.get("partial", "")
58
+ if text:
59
+ yield text
60
+
61
+ result = json.loads(rec.FinalResult())
62
+ text = result.get("text", "")
63
+ if text:
64
+ yield text
65
+
66
+ def listen(self, device=None):
67
+ target_device = device if device is not None else self.device
68
+ try:
69
+ with sd.RawInputStream(
70
+ samplerate=self.sample_rate,
71
+ blocksize=8000,
72
+ device=target_device,
73
+ dtype='int16',
74
+ channels=1,
75
+ callback=self._audio_callback
76
+ ):
77
+ while True:
78
+ data = self.q.get()
79
+ if self.recognizer.AcceptWaveform(data):
80
+ result = json.loads(self.recognizer.Result())
81
+ text = result.get("text", "")
82
+ if text:
83
+ yield text
84
+ else:
85
+ result = json.loads(self.recognizer.PartialResult())
86
+ text = result.get("partial", "")
87
+ if text:
88
+ yield text
89
+ except sd.PortAudioError as e:
90
+ print(f"\n[Error] Could not open audio input device ({target_device}): {e}", file=sys.stderr)
91
+ raise