Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ VOXD automatically discovers all `.gguf` files in the models directory on startu
Edit `~/.config/voxd/config.yaml`:

```yaml
# Typing behavior (for long transcriptions)
typing_chunk_size: 250 # Characters per chunk (prevents ydotool truncation at 285 chars)
typing_inter_chunk_delay: 0.05 # Seconds between chunks (0.05 = 50ms)

# llama.cpp settings
llamacpp_server_path: "llama.cpp/build/bin/llama-server"
llamacpp_server_url: "http://localhost:8080"
Expand All @@ -309,6 +313,29 @@ aipp_selected_models:
llamacpp_server: "qwen2.5-3b-instruct-q4_k_m"
```

#### Typing Long Text

VOXD automatically handles long transcriptions (>285 characters) by chunking text into smaller segments. This prevents `ydotool`'s command-line argument length limitation from truncating your dictation.

**Configuration options:**
- `typing_chunk_size`: Maximum characters per chunk (default: 250)
- Keeps chunks safely below ydotool's 285-character truncation limit
- Reduce if you experience truncation issues (e.g., to 200)
- Increase for faster typing of long text (but stay below 280)

- `typing_inter_chunk_delay`: Delay between chunks in seconds (default: 0.05)
- Adjust if chunks appear to merge incorrectly
- Increase for more reliable typing on slower systems
- Decrease for faster typing (minimum: 0.01)

**Example for very long dictations (500+ characters):**
```yaml
typing_chunk_size: 250
typing_inter_chunk_delay: 0.05
```

This configuration works transparently - no user intervention needed. Short text (<250 chars) uses the fast, non-chunked method automatically.

---

### 🔑 Setting API Keys for the remote API providers
Expand Down
2 changes: 2 additions & 0 deletions src/voxd/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"typing": True,
"typing_delay": 1,
"typing_start_delay": 0.15,
"typing_chunk_size": 250, # Characters per chunk for long text (prevents ydotool truncation at 285)
"typing_inter_chunk_delay": 0.05, # Seconds between chunks (0.05 = 50ms)
"ctrl_v_paste": False, # Use Ctrl+V instead of default Ctrl+Shift+V
"append_trailing_space": True,
"verbosity": False,
Expand Down
66 changes: 59 additions & 7 deletions src/voxd/core/typer.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,17 +277,69 @@ def type(self, text):
except Exception:
t = t

verbo(f"[typer] Typing transcript using {self.tool}...")
# Get chunk size from config (default: 250 to avoid ydotool's 285-char truncation)
chunk_size = 250
inter_chunk_delay = 0.05 # 50ms default
try:
if self.cfg:
chunk_size = int(self.cfg.data.get("typing_chunk_size", 250))
inter_chunk_delay = float(self.cfg.data.get("typing_inter_chunk_delay", 0.05))
except (ValueError, TypeError):
pass # Use defaults if config values are invalid

tool_name = os.path.basename(self.tool) if self.tool else ""
if tool_name == "ydotool" and self.tool:
self._run_tool([self.tool, "type", "-d", self.delay_str, t])
elif tool_name == "xdotool" and self.tool:
self._run_tool([self.tool, "type", "--delay", self.delay_str, t])

# Check if text needs chunking (prevents ydotool truncation at 285 chars)
if len(t) > chunk_size:
verbo(f"[typer] Typing {len(t)} characters using chunked method ({len(t) // chunk_size + 1} chunks)...")
self._type_chunked(t, chunk_size, inter_chunk_delay, tool_name)
else:
print("[typer] ⚠️ No valid typing tool found.")
return
verbo(f"[typer] Typing transcript using {self.tool}...")
if tool_name == "ydotool" and self.tool:
self._run_tool([self.tool, "type", "-d", self.delay_str, t])
elif tool_name == "xdotool" and self.tool:
self._run_tool([self.tool, "type", "--delay", self.delay_str, t])
else:
print("[typer] ⚠️ No valid typing tool found.")
return
self.flush_stdin() # Flush pending input before any new prompt

def _type_chunked(self, text, chunk_size, inter_chunk_delay, tool_name):
"""
Type long text by splitting into chunks to avoid ydotool's 285-character truncation.

Args:
text: The full text to type
chunk_size: Maximum characters per chunk
inter_chunk_delay: Seconds to wait between chunks
tool_name: Name of the typing tool (ydotool/xdotool)
"""
position = 0
chunk_count = (len(text) + chunk_size - 1) // chunk_size # Ceiling division

while position < len(text):
chunk = text[position:position + chunk_size]
chunk_num = (position // chunk_size) + 1

verbo(f"[typer] Chunk {chunk_num}/{chunk_count}: {len(chunk)} characters")

# Type this chunk
if tool_name == "ydotool" and self.tool:
self._run_tool([self.tool, "type", "-d", self.delay_str, chunk])
elif tool_name == "xdotool" and self.tool:
self._run_tool([self.tool, "type", "--delay", self.delay_str, chunk])
else:
print(f"[typer] ⚠️ No valid typing tool found for chunk {chunk_num}.")
return

position += chunk_size

# Add delay between chunks (except after the last chunk)
if position < len(text) and inter_chunk_delay > 0:
time.sleep(inter_chunk_delay)

verbo(f"[typer] Chunked typing completed: {len(text)} characters in {chunk_count} chunks")

# ------------------------------------------------------------------
# Helper: fast clipboard paste
# ------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/voxd/defaults/default_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ perf_accuracy_rating_collect: true
typing: true
typing_delay: 1
typing_start_delay: 0.15
typing_chunk_size: 250 # Characters per chunk (prevents ydotool 285-char truncation)
typing_inter_chunk_delay: 0.05 # Seconds between chunks (0.05 = 50ms)
ctrl_v_paste: false # Use Ctrl+V instead of default Ctrl+Shift+V
append_trailing_space: true
verbosity: false
Expand Down
82 changes: 82 additions & 0 deletions tests/test_core_typer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,85 @@ def test_typer_paste_path(monkeypatch):
# Should not raise
t.type("hello")


def test_typer_chunking_long_text(monkeypatch):
"""Test that long text (>285 chars) gets chunked properly."""
from voxd.core.typer import SimulatedTyper
from unittest.mock import Mock

# Mock config with chunking settings
mock_cfg = Mock()
mock_cfg.data = {
"append_trailing_space": True,
"typing_chunk_size": 250,
"typing_inter_chunk_delay": 0.05
}

# Create typer with mocked tool
monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-1")
t = SimulatedTyper(delay=10, start_delay=0, cfg=mock_cfg)

# Mock the tool path and _run_tool to track calls
t.tool = "/usr/bin/ydotool"
t.enabled = True
call_log = []

def mock_run_tool(cmd):
call_log.append(cmd)

t._run_tool = mock_run_tool

# Create text longer than 285 characters (the truncation point)
long_text = "a" * 300 # 300 chars should trigger chunking with default 250 chunk size

# Type the long text
t.type(long_text)

# Verify multiple chunks were sent
assert len(call_log) > 1, f"Expected multiple chunks, got {len(call_log)} calls"

# Verify each chunk is <= 250 chars (plus trailing space)
for i, cmd in enumerate(call_log):
chunk_text = cmd[-1] # Last element is the text
assert len(chunk_text) <= 251, f"Chunk {i} too long: {len(chunk_text)} chars"

# Verify all text was sent (combining all chunks minus trailing spaces)
combined = "".join(cmd[-1] for cmd in call_log).rstrip()
assert long_text in combined, "Original text not fully present in chunks"


def test_typer_no_chunking_short_text(monkeypatch):
"""Test that short text (<250 chars) doesn't get chunked."""
from voxd.core.typer import SimulatedTyper
from unittest.mock import Mock

# Mock config
mock_cfg = Mock()
mock_cfg.data = {
"append_trailing_space": True,
"typing_chunk_size": 250,
"typing_inter_chunk_delay": 0.05
}

monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-1")
t = SimulatedTyper(delay=10, start_delay=0, cfg=mock_cfg)

# Mock the tool
t.tool = "/usr/bin/ydotool"
t.enabled = True
call_log = []

def mock_run_tool(cmd):
call_log.append(cmd)

t._run_tool = mock_run_tool

# Create short text
short_text = "This is a short test message."

# Type the short text
t.type(short_text)

# Verify only one call was made (no chunking)
assert len(call_log) == 1, f"Expected single call for short text, got {len(call_log)} calls"