Skip to content

Commit f518f8f

Browse files
Isaac Springerclaude
authored andcommitted
perf: parallelize title and tag generation with ThreadPoolExecutor
Submit generate_title and generate_tags concurrently via ThreadPoolExecutor(max_workers=2) in _process_audio, eliminating 2-4s of sequential LLM latency. Adds a concurrency test to verify both calls start within 20ms of each other. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2ff1d9d commit f518f8f

2 files changed

Lines changed: 92 additions & 8 deletions

File tree

tests/test_main.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,77 @@ def test_setup_argparser_has_reset_personas_flag():
151151
main()
152152

153153
mock_seed.assert_called_once_with(force=True)
154+
155+
156+
def test_title_and_tags_generated_in_parallel(tmp_path):
157+
"""generate_title and generate_tags should start concurrently."""
158+
import time
159+
from datetime import datetime
160+
from unittest.mock import MagicMock, patch
161+
from tinysteno.main import _process_audio
162+
from tinysteno.personas import Persona
163+
from pathlib import Path
164+
165+
persona = Persona(
166+
slug="default",
167+
name="Default",
168+
description="desc",
169+
schema={"summary": {"type": "string", "description": "summary"}},
170+
system_prompt="You are a test assistant.",
171+
template="{{ title }}",
172+
template_path=Path("/fake/template.md"),
173+
)
174+
config = {
175+
"api_key": "ollama",
176+
"base_url": "http://localhost",
177+
"model": "test",
178+
"whisper_model": "small",
179+
"diarization": False,
180+
"auto_title": True,
181+
"auto_tags": True,
182+
"obsidian_vault": str(tmp_path),
183+
"output_folder": "meetings",
184+
}
185+
186+
call_start_times: dict = {}
187+
188+
def fake_title(text):
189+
call_start_times["title"] = time.monotonic()
190+
time.sleep(0.05)
191+
return "Test Title"
192+
193+
def fake_tags(text):
194+
call_start_times["tags"] = time.monotonic()
195+
time.sleep(0.05)
196+
return ["test"]
197+
198+
mock_transcribe_result = {
199+
"text": "hello world",
200+
"diarised_text": "",
201+
"detected_language": "en",
202+
"duration_seconds": 10.0,
203+
}
204+
205+
with patch("tinysteno.main.WhisperTranscriber") as mock_tc, \
206+
patch("tinysteno.main.Orchestrator") as mock_oc, \
207+
patch("tinysteno.main.ObsidianExporter") as mock_ec:
208+
209+
mock_tc.return_value.transcribe.return_value = mock_transcribe_result
210+
mock_orch = mock_oc.return_value
211+
mock_orch.summarize.return_value = {"summary": "hello world"}
212+
mock_orch.generate_title.side_effect = fake_title
213+
mock_orch.generate_tags.side_effect = fake_tags
214+
mock_ec.return_value.export.return_value = tmp_path / "note.md"
215+
216+
_process_audio(
217+
wav_path=str(tmp_path / "audio.wav"),
218+
name=None,
219+
config=config,
220+
logger=MagicMock(),
221+
persona=persona,
222+
timestamp=datetime(2024, 1, 1),
223+
)
224+
225+
assert "title" in call_start_times and "tags" in call_start_times
226+
overlap = abs(call_start_times["title"] - call_start_times["tags"])
227+
assert overlap < 0.02, f"title and tags should start concurrently, gap={overlap:.3f}s"

tinysteno/main.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,21 +157,31 @@ def _process_audio( # pylint: disable=too-many-arguments,too-many-positional-ar
157157
all_items.extend(data.get(field, []))
158158
first_string_value = ". ".join(all_items) if all_items else None
159159

160+
# Generate title and tags in parallel when both are enabled
161+
from concurrent.futures import ThreadPoolExecutor
162+
163+
title_future = None
164+
tags_future = None
165+
166+
if (config.get("auto_title") or config.get("auto_tags")) and orchestrator and first_string_value:
167+
print("Generating title and tags...")
168+
with ThreadPoolExecutor(max_workers=2) as executor:
169+
if config.get("auto_title"):
170+
title_future = executor.submit(orchestrator.generate_title, first_string_value)
171+
if config.get("auto_tags"):
172+
tags_future = executor.submit(orchestrator.generate_tags, first_string_value)
173+
160174
# Resolve title
161-
title = name # start with --name if provided
175+
title = name
162176
if not title:
163-
if config.get("auto_title") and orchestrator and first_string_value:
164-
print("Generating title...")
165-
generated = orchestrator.generate_title(first_string_value)
177+
if title_future is not None:
178+
generated = title_future.result()
166179
title = generated if generated else Path(wav_path).stem
167180
else:
168181
title = Path(wav_path).stem
169182

170183
# Resolve generated tags
171-
generated_tags: list = []
172-
if config.get("auto_tags") and orchestrator and first_string_value:
173-
print("Generating tags...")
174-
generated_tags = orchestrator.generate_tags(first_string_value)
184+
generated_tags: list = tags_future.result() if tags_future is not None else []
175185

176186
date_str = timestamp.strftime("%Y-%m-%d %H:%M")
177187
duration_str = _format_duration(result.get("duration_seconds", 0.0))

0 commit comments

Comments
 (0)