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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ Environment variables (see `fast_audio.py`, `readme.md`):
| `chunking.py` | WPM-based text splitting |
| `not_reading.py` | Text sanitization before generation |
| `constants.py` | `VOICE_MAP` (`ka`, `ka-m`, `ru`, `en`, `en-US`), `SSML_LANG_MAP`, HTTP/stream limits |
| `study_session.py` | Pure-logic timing model for study mode: binary-search word lookup over `List[WordEvent]`, chunk boundaries, WPM. No UI/audio deps. |
| `study_player.py` | Tk + pygame synchronized RSVP+audio reader (study mode); excluded from coverage like `gui.py`. Launched from `main.py --study`. Requires the `[study]` extra. |
| `quiz.py` | `QuizProvider` Protocol + `RuleBasedProvider` (zero-dep; `_generate_for_chunk` is intentionally a TODO slot) + `LLMQuizProvider` stub for future swap. |
| `session_log.py` | Append-only JSONL training log at `~/.tts_ka_study.jsonl` (override via `TTS_KA_STUDY_LOG`). `SessionRecord` dataclass with `wpm` property. |
| `extras/autohotkey/` | Windows: `TTS_ka_hotkeys.ahk` (hotkeys + Apps key / Ctrl+Alt+RButton language menu), `Install-TTS_ka-Hotkeys.ps1` (Startup) |
| `extras/windows/context_menu/` | `Install-TTS_ka-ContextMenu.ps1` — nested “Read with TTS_ka” on Explorer/Desktop background (clipboard) |

Expand All @@ -78,3 +82,4 @@ Environment variables (see `fast_audio.py`, `readme.md`):
- **`--live` AI-streaming mode**: `tts-ka --live -l en` reads stdin line-by-line, accumulates in `SentenceBuffer`, flushes on `[.!?]+\s`, paragraph break, or idle (default 800 ms via `--live-idle-ms`). Code fences (` ``` `) are held open until closed so `not_reading.replace_not_readable` can collapse them; per-sentence MP3s feed into `StreamingAudioPlayer` with `chunk_index` ordering. Use case: `claude --print | tts-ka --live`.
- **MCP server (`TTS_ka-mcp`)**: stdio JSON-RPC server (`pip install -e ".[mcp]"`). Tools: `speak`, `stream_open`, `stream_append`, `stream_close`, `session_status`, `list_sessions`, `stop`, `list_voices`. `_LiveSession` tracks two counters: `_idx` (output-file numbering, ticks inside `_speak`) and `_queued` (status-visible, ticks when `feed` extracts a sentence — so an agent can see backed-up synths via `synths_pending = _queued - done_tasks`). `build_server(sessions=dict)` factory lets tests inject a session dict for inspection. Configure in Claude Code: `{"mcpServers": {"tts-ka": {"command": "TTS_ka-mcp"}}}`.
- **MCP stdout duality** (`_run_with_preserved_stdout`): naively swapping `sys.stdout = sys.stderr` to silence library prints ALSO kills MCP framing because `mcp.server.stdio` reads `sys.stdout.buffer` at handshake time. The fix is fd-level: `os.dup(1)` saves the original stdout fd, `os.dup2(2, 1)` redirects Python-level stdout (and any subprocess inheriting fd 1) to stderr, and the saved fd is wrapped and handed to `stdio_server(stdout=...)`. Without this, the E2E test hangs on `session.initialize()` because the server's reply lands on stderr.
- **Study mode (`--study`)**: synchronized RSVP word-flash + audio playback over the same text. Reuses `fast_audio.generate_audio_with_subs` for the per-word `WordEvent` timing list; `study_session.StudySession` is the pure-logic model (binary-search lookup via `bisect_right` on a pre-extracted `_starts` array, O(log n) inside the 30 Hz sync loop). The Tk player polls `pygame.mixer.music.get_pos()` every 33 ms and updates a centred big-word label plus faded peripheral context. Quiz generation lives behind a `QuizProvider` Protocol so a future LLM backend can swap in without touching the player; the default `RuleBasedProvider._generate_for_chunk` is a TODO(human) slot — wrap of the slot raises `NotImplementedError`, intentionally, so the player surfaces it instead of silently returning empty results. Session log is plain JSONL at `~/.tts_ka_study.jsonl`.
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ server = [
mcp = [
"mcp>=1.0.0",
]
study = [
"pygame>=2.5.0",
]
test = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
Expand Down Expand Up @@ -105,6 +108,7 @@ omit = [
"*/.venv/*",
"*/TTS_ka/gui.py",
"*/TTS_ka/native_hotkeys.py",
"*/TTS_ka/study_player.py",
]

[tool.coverage.report]
Expand Down
30 changes: 30 additions & 0 deletions src/TTS_ka/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,18 @@ def main() -> None:
default=800,
help="In --live mode, flush a partial sentence after this many ms of stdin silence (default 800).",
)
parser.add_argument(
"--study",
action="store_true",
help="Open the synchronized RSVP + audio study window for the input text "
"(requires the [study] extra: `pip install -e \".[study]\"`).",
)
parser.add_argument(
"--study-chunk-words",
type=int,
default=200,
help="Words per chunk for study-mode comprehension checks (default 200).",
)

args = parser.parse_args(argv_rest)

Expand Down Expand Up @@ -639,6 +651,24 @@ def main() -> None:
print("Error: No text provided")
return

if args.study:
try:
from .study_player import launch as _study_launch
except ImportError as exc:
print(
f"Error: study mode requires the 'study' extra. "
f"Install with: pip install -e \".[study]\" ({exc})",
file=sys.stderr,
)
raise SystemExit(2)
_study_launch(
text=text,
language=args.lang,
rate=args.rate,
chunk_size_words=args.study_chunk_words,
)
return

# Overwrite protection applies only to explicit CLI --output / -o, not config defaults.
output_explicit = any(
a == "-o" or a == "--output" or a.startswith("--output=") or a.startswith("-o=")
Expand Down
108 changes: 108 additions & 0 deletions src/TTS_ka/quiz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Comprehension-check generator for study-mode chunks.

The design intent is *one swappable Protocol*: ``QuizProvider``. The default
implementation, :class:`RuleBasedProvider`, has no external dependencies and
produces simple retention questions from plain text. Cloud or local LLM
providers can be added later without touching the player or session state —
they only need to satisfy the :class:`QuizProvider` interface.

A :class:`Question` is intentionally generic — it carries the prompt and an
optional list of expected keywords that callers can use for naive
self-grading (keyword overlap) or simply display alongside a "did you get
it?" yes/no toggle.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import List, Protocol, runtime_checkable


@dataclass(frozen=True)
class Question:
"""A single retention prompt for a chunk of text."""

text: str
expected_keywords: List[str] = field(default_factory=list)
kind: str = "open" # "open" | "fill-blank" | "factual" | "summary"


@runtime_checkable
class QuizProvider(Protocol):
"""Anything that can turn a chunk of text into retention questions."""

def questions(self, chunk_text: str, n: int = 3) -> List[Question]:
...


class RuleBasedProvider:
"""Zero-dependency provider that generates questions via simple rules.

The actual question-generation strategy is intentionally left to the
project owner — see ``_generate_for_chunk`` below. This class handles
the surrounding plumbing (empty-input guard, n-question cap, public
Protocol surface) so that any rule design plugs in cleanly.
"""

def questions(self, chunk_text: str, n: int = 3) -> List[Question]:
text = (chunk_text or "").strip()
if not text:
return []
if n <= 0:
return []
generated = self._generate_for_chunk(text)
return generated[:n]

def _generate_for_chunk(self, chunk_text: str) -> List[Question]:
# TODO(human): implement the rule-based question-generation strategy.
#
# Inputs:
# chunk_text — a stripped, non-empty string (typically ~200 words
# of narrative prose).
#
# Output:
# A list of Question objects. The public .questions() wrapper
# will cap this at the caller's requested `n`, so generating
# slightly more than needed is fine.
#
# Design space to consider (pick a lane and document it):
# 1) Fill-in-the-blank: pick "important" tokens (longest words,
# capitalized non-sentence-start words, named entities found
# by regex on Title Case patterns), and produce
# "Original sentence with ____ replacing the picked token".
# Kind: "fill-blank". Expected keywords: [the blanked token].
#
# 2) Who/What/When template: scan for proper nouns and numbers,
# construct "Who is X?" / "What happened at Y?" prompts.
# Kind: "factual". Expected keywords: surrounding context words.
#
# 3) Summary prompt: a single open question like
# "In 1-2 sentences, what happened in this chunk?" plus 1-2
# anchor keywords pulled from the chunk to self-grade against.
# Kind: "summary". Expected keywords: top-frequency content
# words after stopword removal.
#
# Honest trade-off: rule-based questions are stupid by LLM standards
# but they ARE good enough to answer "did I actually read this?",
# which is the only metric the session log needs. Don't over-engineer.
raise NotImplementedError(
"RuleBasedProvider._generate_for_chunk is the TODO(human) slot."
)


class LLMQuizProvider:
"""Placeholder for a future cloud/local LLM provider.

Implement by satisfying the :class:`QuizProvider` Protocol —
typically: prompt the model with the chunk + a system instruction
requesting N retention questions in JSON, parse, return.
"""

def __init__(self, *_args: object, **_kwargs: object) -> None:
raise NotImplementedError(
"LLMQuizProvider is a stub. Pick a backend (Anthropic, OpenAI, "
"Ollama) and implement .questions() against the QuizProvider Protocol."
)

def questions(self, chunk_text: str, n: int = 3) -> List[Question]:
raise NotImplementedError
81 changes: 81 additions & 0 deletions src/TTS_ka/session_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Append-only training log for study-mode sessions.

One line per session, JSONL, stored at ``~/.tts_ka_study.jsonl`` (matching the
sibling convention in :mod:`user_config` for ``.tts_config.json``).

The log is the substrate for later analysis: WPM trajectory over weeks,
chunks-per-session, answer-correctness rate. Keeping the format flat and
append-only means a graphing script can be a one-liner over the file later;
schema changes are handled by adding new optional fields, not by migration.
"""

from __future__ import annotations

import json
import os
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional


@dataclass(frozen=True)
class SessionRecord:
"""One study session, ready to serialize as a JSONL line."""

date: str # ISO-8601 UTC, e.g. "2026-06-04T11:23:00+00:00"
text_hash: str
voice: str
word_count: int
duration_ms: int
chunks_done: int
questions_asked: int = 0
questions_correct: int = 0
notes: str = ""
extra: dict = field(default_factory=dict)

@property
def wpm(self) -> float:
if self.duration_ms <= 0:
return 0.0
return self.word_count * 60_000.0 / self.duration_ms

@classmethod
def now(cls, **kwargs: object) -> "SessionRecord":
return cls(date=datetime.now(timezone.utc).isoformat(), **kwargs) # type: ignore[arg-type]


def default_log_path() -> Path:
"""``~/.tts_ka_study.jsonl`` — overridable via ``TTS_KA_STUDY_LOG``."""
env = os.environ.get("TTS_KA_STUDY_LOG", "").strip()
if env:
return Path(os.path.expanduser(env))
return Path.home() / ".tts_ka_study.jsonl"


def append(record: SessionRecord, path: Optional[Path] = None) -> Path:
"""Append a record as one JSON line. Returns the path written to."""
target = path or default_log_path()
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("a", encoding="utf-8") as f:
f.write(json.dumps(asdict(record), ensure_ascii=False) + "\n")
return target


def read_all(path: Optional[Path] = None) -> List[SessionRecord]:
"""Read every session record. Malformed lines are skipped, not raised."""
target = path or default_log_path()
if not target.exists():
return []
out: List[SessionRecord] = []
with target.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
out.append(SessionRecord(**data))
except (json.JSONDecodeError, TypeError):
continue
return out
Loading
Loading