fix(dictate): finish Muse on final transcript

This commit is contained in:
jb
2026-09-03 13:06:33 -04:00
parent 2c3ee1f014
commit 0423ec3676
8 changed files with 91 additions and 14 deletions
+2 -2
View File
@@ -126,7 +126,7 @@ Inside Pi:
The currently published and deployed extension set is: 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-effort-controls@0.1.0`;
- `@jbcode/pi-prompt-snippets@0.1.0`; - `@jbcode/pi-prompt-snippets@0.1.0`;
- `@jbcode/pi-interactive-subagents@3.7.3`; - `@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 ```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-effort-controls@0.1.0
pi install npm:@jbcode/pi-prompt-snippets@0.1.0 pi install npm:@jbcode/pi-prompt-snippets@0.1.0
pi install npm:@jbcode/pi-interactive-subagents@3.7.3 pi install npm:@jbcode/pi-interactive-subagents@3.7.3
+1 -1
View File
@@ -27,7 +27,7 @@
}, },
"extensions/dictate": { "extensions/dictate": {
"name": "@jbcode/pi-dictate", "name": "@jbcode/pi-dictate",
"version": "0.1.0", "version": "0.2.0",
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-coding-agent": "catalog:pi-peer", "@earendil-works/pi-coding-agent": "catalog:pi-peer",
}, },
@@ -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. - 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`. - `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. - 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. - 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. - `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. - 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.
+1 -1
View File
@@ -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: 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 ```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-effort-controls@0.1.0
pi install npm:@jbcode/pi-prompt-snippets@0.1.0 pi install npm:@jbcode/pi-prompt-snippets@0.1.0
pi install npm:@jbcode/pi-interactive-subagents@3.7.3 pi install npm:@jbcode/pi-interactive-subagents@3.7.3
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jbcode/pi-dictate", "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.", "description": "Pi extension for local Linux voice dictation through a Unix socket service.",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -422,8 +422,11 @@ class QueuedCloudTurn:
self._provider_event_bytes -= size self._provider_event_bytes -= size
raise ProviderError(self._provider_prefix + "_event_overflow", "provider event queue is full") from error raise ProviderError(self._provider_prefix + "_event_overflow", "provider event queue is full") from error
except ProviderError as error: except ProviderError as error:
if not self._cancel_event.is_set(): # 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 self._receiver_error = error
if not self._cancel_event.is_set():
self._abort_transport() self._abort_transport()
except Exception: except Exception:
if not self._cancel_event.is_set(): if not self._cancel_event.is_set():
@@ -177,13 +177,10 @@ class MetaTurn(QueuedCloudTurn):
def _finish_provider(self) -> str: def _finish_provider(self) -> str:
deadline = time.monotonic() + self._timeout deadline = time.monotonic() + self._timeout
transcript: str | None = None
while time.monotonic() < deadline: while time.monotonic() < deadline:
event = self._next_provider_event(max(0.01, deadline - time.monotonic())) event = self._next_provider_event(max(0.01, deadline - time.monotonic()))
if event is None: if event is None:
if transcript is None:
raise ProviderError("meta_premature_close", "Meta closed without a final transcript") raise ProviderError("meta_premature_close", "Meta closed without a final transcript")
return transcript
_validate_meta_event(event) _validate_meta_event(event)
event_type = event["type"] event_type = event["type"]
if event_type == "error": if event_type == "error":
@@ -198,11 +195,9 @@ class MetaTurn(QueuedCloudTurn):
if isinstance(value, Mapping): if isinstance(value, Mapping):
value = value.get("text") value = value.get("text")
try: try:
transcript = bounded_provider_text(value) return bounded_provider_text(value)
except ProviderError as error: except ProviderError as error:
raise ProviderError("meta_malformed_event", "Meta sent an invalid transcript") from 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") raise ProviderError("meta_timeout", "timed out waiting for Meta final transcript")
+79
View File
@@ -59,6 +59,44 @@ class FakeTransport:
self.closed = True 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): class BlockingCloseTransport(FakeTransport):
def __init__(self, events: list[str | bytes | None]) -> None: def __init__(self, events: list[str | bytes | None]) -> None:
super().__init__(events) super().__init__(events)
@@ -137,6 +175,47 @@ class BackendTests(unittest.TestCase):
self.assertEqual(transport.sent[1], b"pcm") self.assertEqual(transport.sent[1], b"pcm")
self.assertEqual(json.loads(transport.sent[2]), {"type": "endStream"}) 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: def test_openai_uses_ga_session_shape_and_correlates_committed_item(self) -> None:
key, terms, temporary = self.make_files() key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup) self.addCleanup(temporary.cleanup)