diff --git a/README.md b/README.md index e91073f..22ede1c 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Inside Pi: The currently published and deployed extension set is: -- `@jbcode/pi-dictate@0.1.0`; +- `@jbcode/pi-dictate@0.2.0`; - `@jbcode/pi-effort-controls@0.1.0`; - `@jbcode/pi-prompt-snippets@0.1.0`; - `@jbcode/pi-interactive-subagents@3.7.3`; @@ -140,7 +140,7 @@ Configure the scoped Gitea npm registry and install only the packages you want. ``` ```bash -pi install npm:@jbcode/pi-dictate@0.1.0 +pi install npm:@jbcode/pi-dictate@0.2.0 pi install npm:@jbcode/pi-effort-controls@0.1.0 pi install npm:@jbcode/pi-prompt-snippets@0.1.0 pi install npm:@jbcode/pi-interactive-subagents@3.7.3 diff --git a/bun.lock b/bun.lock index 4d2ecb5..f99b7d0 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "extensions/dictate": { "name": "@jbcode/pi-dictate", - "version": "0.1.0", + "version": "0.2.0", "peerDependencies": { "@earendil-works/pi-coding-agent": "catalog:pi-peer", }, diff --git a/docs/dictate/configurable-asr-backends-plan.md b/docs/dictate/configurable-asr-backends-plan.md index 6d65985..1d10d86 100644 --- a/docs/dictate/configurable-asr-backends-plan.md +++ b/docs/dictate/configurable-asr-backends-plan.md @@ -68,7 +68,7 @@ Current documented shape: - The public API accepts signed little-endian mono PCM16 as binary frames. Use native `PCM_24KHZ` (48,000 bytes/second); `PCM_16KHZ` is also accepted and server-resampled. - `PUSH_TO_TALK` is the mode matching Dictate's one explicit turn. Defaults also include `partialMode: "CUMULATIVE"` and `emitAudioProgress: true`. - Wait for the server's `{ "sessionId": "..." }` acknowledgement before sending audio. Frames carry no headers, timestamps, sequence numbers, or semantic boundaries; approximately 80 ms pacing is guidance rather than a required frame size. -- End input with the text frame `{ "type": "endStream" }`, continue reading, require a `transcript` event with `final: true`, then accept normal close code `1000`. +- End input with the text frame `{ "type": "endStream" }` and require a `transcript` event with `final: true`. Treat that correlated final as completion and close the connection rather than waiting for Meta's idle close, while preserving any provider failure that races with finalization. - Cumulative partials replace the prior hypothesis. Dictate ignores partials, turn boundaries, endpointing, and speaker events and retains only the explicit bounded final transcript. - `keywords` and `languageBias` are handshake-only hints. The existing coding dictionary can feed `keywords`; no free-form prompt field is documented. - Meta documents a 60-minute session, at most five seconds of audio backlog with approximately realtime pacing, eight concurrent streams, and 1,000 streams/hour. Dictate's own five-minute cap remains stricter. diff --git a/docs/npm-packages.md b/docs/npm-packages.md index e77306a..10b6b23 100644 --- a/docs/npm-packages.md +++ b/docs/npm-packages.md @@ -49,7 +49,7 @@ Publish `@jbcode/pi-shared` before a new version of any extension that depends o After configuring the same scope and token in the npm configuration visible to Pi, install the currently deployed release set with exact version pins: ```bash -pi install npm:@jbcode/pi-dictate@0.1.0 +pi install npm:@jbcode/pi-dictate@0.2.0 pi install npm:@jbcode/pi-effort-controls@0.1.0 pi install npm:@jbcode/pi-prompt-snippets@0.1.0 pi install npm:@jbcode/pi-interactive-subagents@3.7.3 diff --git a/extensions/dictate/package.json b/extensions/dictate/package.json index ec28299..0084c8c 100644 --- a/extensions/dictate/package.json +++ b/extensions/dictate/package.json @@ -1,6 +1,6 @@ { "name": "@jbcode/pi-dictate", - "version": "0.1.0", + "version": "0.2.0", "description": "Pi extension for local Linux voice dictation through a Unix socket service.", "type": "module", "license": "MIT", diff --git a/services/dictate/src/pi_dictate_service/backend.py b/services/dictate/src/pi_dictate_service/backend.py index 5c26828..6f62b11 100644 --- a/services/dictate/src/pi_dictate_service/backend.py +++ b/services/dictate/src/pi_dictate_service/backend.py @@ -422,8 +422,11 @@ class QueuedCloudTurn: self._provider_event_bytes -= size raise ProviderError(self._provider_prefix + "_event_overflow", "provider event queue is full") from error except ProviderError as error: + # Preserve a provider failure that races with successful + # finalization. Cancellation does not consume this error, but it + # avoids recursively closing a transport we are already closing. + self._receiver_error = error if not self._cancel_event.is_set(): - self._receiver_error = error self._abort_transport() except Exception: if not self._cancel_event.is_set(): diff --git a/services/dictate/src/pi_dictate_service/backends/meta.py b/services/dictate/src/pi_dictate_service/backends/meta.py index 4d7ab3f..2fbb77b 100644 --- a/services/dictate/src/pi_dictate_service/backends/meta.py +++ b/services/dictate/src/pi_dictate_service/backends/meta.py @@ -177,13 +177,10 @@ class MetaTurn(QueuedCloudTurn): def _finish_provider(self) -> str: deadline = time.monotonic() + self._timeout - transcript: str | None = None while time.monotonic() < deadline: event = self._next_provider_event(max(0.01, deadline - time.monotonic())) if event is None: - if transcript is None: - raise ProviderError("meta_premature_close", "Meta closed without a final transcript") - return transcript + raise ProviderError("meta_premature_close", "Meta closed without a final transcript") _validate_meta_event(event) event_type = event["type"] if event_type == "error": @@ -198,11 +195,9 @@ class MetaTurn(QueuedCloudTurn): if isinstance(value, Mapping): value = value.get("text") try: - transcript = bounded_provider_text(value) + return bounded_provider_text(value) except ProviderError as error: raise ProviderError("meta_malformed_event", "Meta sent an invalid transcript") from error - # Keep reading until the provider's normal close. A subsequent - # error or abnormal close therefore cannot be mistaken for success. raise ProviderError("meta_timeout", "timed out waiting for Meta final transcript") diff --git a/services/dictate/tests/test_backends.py b/services/dictate/tests/test_backends.py index 8ed6a86..76650e1 100644 --- a/services/dictate/tests/test_backends.py +++ b/services/dictate/tests/test_backends.py @@ -59,6 +59,44 @@ class FakeTransport: self.closed = True +class PersistentTransport(FakeTransport): + """Keep the provider session open after all queued events are delivered.""" + + def __init__(self, events: list[str | bytes | None]) -> None: + super().__init__(events) + self.close_requested = threading.Event() + + def recv(self, timeout: float | None = None) -> str | bytes | None: + if self.events: + return self.events.pop(0) + if self.close_requested.wait(timeout): + return None + raise TimeoutError + + def close(self) -> None: + self.close_requested.set() + super().close() + + +class ErrorOnCloseTransport(PersistentTransport): + """Release a provider error concurrently with client-initiated closure.""" + + def __init__(self, events: list[str | bytes | None], error_event: str) -> None: + super().__init__(events) + self.error_event = error_event + self.error_delivered = False + + def recv(self, timeout: float | None = None) -> str | bytes | None: + if self.events: + return self.events.pop(0) + if not self.close_requested.wait(timeout): + raise TimeoutError + if not self.error_delivered: + self.error_delivered = True + return self.error_event + return None + + class BlockingCloseTransport(FakeTransport): def __init__(self, events: list[str | bytes | None]) -> None: super().__init__(events) @@ -137,6 +175,47 @@ class BackendTests(unittest.TestCase): self.assertEqual(transport.sent[1], b"pcm") self.assertEqual(json.loads(transport.sent[2]), {"type": "endStream"}) + def test_meta_final_transcript_does_not_wait_for_provider_close(self) -> None: + key, terms, temporary = self.make_files() + self.addCleanup(temporary.cleanup) + acknowledgement = (FIXTURE_ROOT / "meta" / "session_ack.json").read_text(encoding="utf-8").strip() + transport = PersistentTransport([acknowledgement, *fixture_events("meta")]) + backend = MetaBackend( + MetaConfig("muse-voice-transcribe-1.0", key, terms), + transport_factory=lambda _endpoint: transport, + timeout=1, + ) + + turn = backend.start_turn() + turn.enqueue_pcm(b"pcm") + started_at = time.monotonic() + self.assertEqual(turn.finish(), "final fixture transcript") + + self.assertLess(time.monotonic() - started_at, 0.5) + self.assertTrue(transport.closed) + + def test_meta_provider_error_racing_with_finalization_is_authoritative(self) -> None: + key, terms, temporary = self.make_files() + self.addCleanup(temporary.cleanup) + acknowledgement = (FIXTURE_ROOT / "meta" / "session_ack.json").read_text(encoding="utf-8").strip() + transport = ErrorOnCloseTransport( + [acknowledgement, *fixture_events("meta")], + json.dumps({"type": "error", "code": "provider_failure"}), + ) + backend = MetaBackend( + MetaConfig("muse-voice-transcribe-1.0", key, terms), + transport_factory=lambda _endpoint: transport, + timeout=1, + ) + + turn = backend.start_turn() + turn.enqueue_pcm(b"pcm") + with self.assertRaises(ProviderError) as error: + turn.finish() + + self.assertEqual(error.exception.code, "meta_error") + self.assertTrue(transport.error_delivered) + def test_openai_uses_ga_session_shape_and_correlates_committed_item(self) -> None: key, terms, temporary = self.make_files() self.addCleanup(temporary.cleanup)