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.
- clicue-0.1.2/.gitignore +15 -0
- clicue-0.1.2/PKG-INFO +9 -0
- clicue-0.1.2/README.md +0 -0
- clicue-0.1.2/pyproject.toml +23 -0
- clicue-0.1.2/src/clicue/__init__.py +0 -0
- clicue-0.1.2/src/clicue/aligner.py +73 -0
- clicue-0.1.2/src/clicue/config.py +56 -0
- clicue-0.1.2/src/clicue/fountain.py +91 -0
- clicue-0.1.2/src/clicue/listener.py +12 -0
- clicue-0.1.2/src/clicue/main.py +224 -0
- clicue-0.1.2/src/clicue/scroller.py +110 -0
- clicue-0.1.2/src/clicue/stt/__init__.py +15 -0
- clicue-0.1.2/src/clicue/stt/base.py +20 -0
- clicue-0.1.2/src/clicue/stt/vosk_engine.py +91 -0
clicue-0.1.2/.gitignore
ADDED
clicue-0.1.2/PKG-INFO
ADDED
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
|