refactor(voice): defer speech confirmation and add diagnostics
This commit is contained in:
@@ -1,581 +0,0 @@
|
|||||||
# Local Voice Turn Detection Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
For local realtime voice, the client should stream PCM continuously to the server. The server should own turn detection using bundled Sherpa + Silero VAD. Client-side logic stays only for meter/activation visuals and is not authoritative for turn boundaries.
|
|
||||||
|
|
||||||
The server should start buffering immediately so we do not clip the beginning of speech.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This plan is intentionally local-only for now.
|
|
||||||
|
|
||||||
- Turn detection: local only
|
|
||||||
- STT: local only
|
|
||||||
- TTS: unchanged
|
|
||||||
- Client VAD: removed as an authority, retained only as UI metering/activation
|
|
||||||
|
|
||||||
## Design Summary
|
|
||||||
|
|
||||||
### Boundaries
|
|
||||||
|
|
||||||
- Client
|
|
||||||
- Captures PCM
|
|
||||||
- Streams fixed-size chunks continuously during voice mode
|
|
||||||
- Shows volume meter / activation visuals
|
|
||||||
- Does not decide turn start or turn end
|
|
||||||
- Server turn detection
|
|
||||||
- Consumes live PCM stream
|
|
||||||
- Runs local Silero VAD through Sherpa
|
|
||||||
- Emits normalized events:
|
|
||||||
- `speech_started`
|
|
||||||
- `speech_stopped`
|
|
||||||
- Server voice turn controller
|
|
||||||
- Owns barge-in
|
|
||||||
- Owns rolling pre-speech buffer
|
|
||||||
- Owns utterance lifecycle
|
|
||||||
- Feeds utterance audio into the existing STT path
|
|
||||||
- Server STT
|
|
||||||
- Transcribes utterance audio selected by turn detection
|
|
||||||
|
|
||||||
## Core Principle
|
|
||||||
|
|
||||||
Do not hide VAD inside STT.
|
|
||||||
|
|
||||||
If STT owns VAD, the abstraction gets coupled again. Turn detection and transcription are different responsibilities and should be modeled separately.
|
|
||||||
|
|
||||||
Bad shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface SpeechToTextProvider {
|
|
||||||
createSession(): {
|
|
||||||
appendPcm16(chunk: Buffer): void;
|
|
||||||
commit(): void;
|
|
||||||
on(event: "speech_started" | "speech_stopped" | "transcript", handler: unknown): unknown;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Better shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface TurnDetectionProvider {
|
|
||||||
createSession(input: {
|
|
||||||
logger: pino.Logger;
|
|
||||||
}): TurnDetectionSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TurnDetectionSession {
|
|
||||||
requiredSampleRate: number;
|
|
||||||
connect(): Promise<void>;
|
|
||||||
appendPcm16(chunk: Buffer): void;
|
|
||||||
close(): void;
|
|
||||||
|
|
||||||
on(event: "speech_started", handler: () => void): unknown;
|
|
||||||
on(event: "speech_stopped", handler: () => void): unknown;
|
|
||||||
on(event: "error", handler: (err: unknown) => void): unknown;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And keep STT separate:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface SpeechToTextProvider {
|
|
||||||
id: "local" | "openai" | (string & {});
|
|
||||||
createSession(input: {
|
|
||||||
logger: pino.Logger;
|
|
||||||
language?: string;
|
|
||||||
prompt?: string;
|
|
||||||
}): StreamingTranscriptionSession;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Config Shape
|
|
||||||
|
|
||||||
Even in a local-only rollout, the boundary should be explicit now so it does not need a second refactor later.
|
|
||||||
|
|
||||||
Current shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type RequestedSpeechProviders = {
|
|
||||||
dictationStt: RequestedSpeechProvider;
|
|
||||||
voiceStt: RequestedSpeechProvider;
|
|
||||||
voiceTts: RequestedSpeechProvider;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Proposed shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type RequestedSpeechProviders = {
|
|
||||||
dictationStt: RequestedSpeechProvider;
|
|
||||||
voiceTurnDetection: RequestedSpeechProvider;
|
|
||||||
voiceStt: RequestedSpeechProvider;
|
|
||||||
voiceTts: RequestedSpeechProvider;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
For the first implementation, resolution can still force local:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const providers = {
|
|
||||||
dictationStt: { provider: "local", explicit: false, enabled: true },
|
|
||||||
voiceTurnDetection: { provider: "local", explicit: false, enabled: true },
|
|
||||||
voiceStt: { provider: "local", explicit: false, enabled: true },
|
|
||||||
voiceTts: { provider: "local", explicit: false, enabled: true },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Runtime Shape
|
|
||||||
|
|
||||||
### 1. Local Turn Detection Provider
|
|
||||||
|
|
||||||
Add a local provider parallel to local STT/TTS runtime wiring.
|
|
||||||
|
|
||||||
Suggested files:
|
|
||||||
|
|
||||||
- `packages/server/src/server/speech/turn-detection-provider.ts`
|
|
||||||
- `packages/server/src/server/speech/providers/local/sherpa/silero-vad-session.ts`
|
|
||||||
- `packages/server/src/server/speech/providers/local/sherpa/silero-vad-provider.ts`
|
|
||||||
|
|
||||||
Expanded local runtime output:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface InitializedLocalSpeech {
|
|
||||||
sttService: SpeechToTextProvider | null;
|
|
||||||
ttsService: TextToSpeechProvider | null;
|
|
||||||
dictationSttService: SpeechToTextProvider | null;
|
|
||||||
turnDetectionService: TurnDetectionProvider | null;
|
|
||||||
cleanup: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Provider construction in local runtime:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (
|
|
||||||
providers.voiceTurnDetection.enabled !== false &&
|
|
||||||
providers.voiceTurnDetection.provider === "local"
|
|
||||||
) {
|
|
||||||
turnDetectionService = new SherpaSileroTurnDetectionProvider(
|
|
||||||
{
|
|
||||||
modelPath: bundledSileroModelPath,
|
|
||||||
sampleRate: 16000,
|
|
||||||
},
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Voice Turn Controller
|
|
||||||
|
|
||||||
This should sit in the server session layer and own the utterance state machine.
|
|
||||||
|
|
||||||
Suggested file:
|
|
||||||
|
|
||||||
- `packages/server/src/server/voice/voice-turn-controller.ts`
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
- receive continuous PCM chunks
|
|
||||||
- write chunks into a rolling pre-speech buffer
|
|
||||||
- feed chunks into detector
|
|
||||||
- on `speech_started`
|
|
||||||
- perform barge-in
|
|
||||||
- start a new utterance
|
|
||||||
- flush pre-speech buffer into utterance
|
|
||||||
- while speech is active
|
|
||||||
- append live chunks into utterance
|
|
||||||
- on `speech_stopped`
|
|
||||||
- finalize utterance into STT
|
|
||||||
|
|
||||||
Public shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface VoiceTurnController {
|
|
||||||
start(): Promise<void>;
|
|
||||||
stop(): Promise<void>;
|
|
||||||
appendClientChunk(input: {
|
|
||||||
audioBase64: string;
|
|
||||||
format: string;
|
|
||||||
}): Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Internal state should be explicit:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type VoiceInputState =
|
|
||||||
| { status: "idle" }
|
|
||||||
| {
|
|
||||||
status: "listening";
|
|
||||||
rollingPrefixBytes: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
status: "capturing";
|
|
||||||
utteranceId: string;
|
|
||||||
rollingPrefixBytes: number;
|
|
||||||
utteranceBytes: number;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
That is better than several booleans like `speechInProgress`, `hasStarted`, or `awaitingStop`.
|
|
||||||
|
|
||||||
## Immediate Buffering Strategy
|
|
||||||
|
|
||||||
Because buffering starts immediately, the detector does not decide whether audio is kept at all. It decides when buffered audio becomes an utterance.
|
|
||||||
|
|
||||||
### Rolling Prefix Buffer
|
|
||||||
|
|
||||||
Maintain a small ring buffer of recent PCM before speech start.
|
|
||||||
|
|
||||||
Suggested defaults:
|
|
||||||
|
|
||||||
- sample rate: `16000`
|
|
||||||
- mono PCM16
|
|
||||||
- prefix duration: `300ms` to `500ms`
|
|
||||||
|
|
||||||
Example utility:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface RollingPcmBuffer {
|
|
||||||
append(chunk: Buffer): void;
|
|
||||||
drain(): Buffer;
|
|
||||||
clear(): void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Example implementation shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
class FixedDurationPcmRingBuffer {
|
|
||||||
private chunks: Buffer[] = [];
|
|
||||||
private totalBytes = 0;
|
|
||||||
|
|
||||||
constructor(private readonly maxBytes: number) {}
|
|
||||||
|
|
||||||
append(chunk: Buffer): void {
|
|
||||||
if (chunk.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.chunks.push(chunk);
|
|
||||||
this.totalBytes += chunk.length;
|
|
||||||
|
|
||||||
while (this.totalBytes > this.maxBytes && this.chunks.length > 0) {
|
|
||||||
const removed = this.chunks.shift()!;
|
|
||||||
this.totalBytes -= removed.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drain(): Buffer {
|
|
||||||
return Buffer.concat(this.chunks);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.chunks = [];
|
|
||||||
this.totalBytes = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flow
|
|
||||||
|
|
||||||
Per incoming chunk:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
prefixBuffer.append(chunk);
|
|
||||||
detector.appendPcm16(chunk);
|
|
||||||
|
|
||||||
if (state.status === "capturing") {
|
|
||||||
utteranceChunks.push(chunk);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
On speech start:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
async function onSpeechStarted(): Promise<void> {
|
|
||||||
await interruptPlaybackAndAgentRun();
|
|
||||||
|
|
||||||
const prefix = prefixBuffer.drain();
|
|
||||||
const utteranceChunks = prefix.length > 0 ? [prefix] : [];
|
|
||||||
|
|
||||||
state = {
|
|
||||||
status: "capturing",
|
|
||||||
utteranceId: uuidv4(),
|
|
||||||
rollingPrefixBytes: prefix.length,
|
|
||||||
utteranceBytes: prefix.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
activeUtteranceChunks = utteranceChunks;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
On speech stop:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
async function onSpeechStopped(): Promise<void> {
|
|
||||||
if (state.status !== "capturing") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pcm16 = Buffer.concat(activeUtteranceChunks);
|
|
||||||
|
|
||||||
await submitUtterance({
|
|
||||||
pcm16,
|
|
||||||
sampleRate: 16000,
|
|
||||||
format: "audio/pcm;rate=16000;bits=16",
|
|
||||||
});
|
|
||||||
|
|
||||||
activeUtteranceChunks = [];
|
|
||||||
state = {
|
|
||||||
status: "listening",
|
|
||||||
rollingPrefixBytes: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## STT Integration
|
|
||||||
|
|
||||||
Do not make the turn controller talk to STT one chunk at a time unless there is a strong need.
|
|
||||||
|
|
||||||
For the first version, the simplest shape is:
|
|
||||||
|
|
||||||
- detector runs on the continuous stream
|
|
||||||
- utterance audio is accumulated in memory
|
|
||||||
- on stop, the utterance goes through the existing internal voice dictation path
|
|
||||||
|
|
||||||
That minimizes refactor risk and keeps the first migration focused on authority and layering.
|
|
||||||
|
|
||||||
Boundary:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface VoiceUtteranceSink {
|
|
||||||
submitUtterance(input: {
|
|
||||||
pcm16: Buffer;
|
|
||||||
sampleRate: number;
|
|
||||||
format: string;
|
|
||||||
}): Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then `session.ts` can implement that sink using the existing voice dictation internals.
|
|
||||||
|
|
||||||
## Session Refactor
|
|
||||||
|
|
||||||
Current problem:
|
|
||||||
|
|
||||||
- raw `voice_audio_chunk` handling triggers speech-start behavior too early
|
|
||||||
- the client remains the authority for `isLast`
|
|
||||||
|
|
||||||
Target shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
private async handleAudioChunk(
|
|
||||||
msg: Extract<SessionInboundMessage, { type: "voice_audio_chunk" }>
|
|
||||||
): Promise<void> {
|
|
||||||
if (this.isVoiceMode) {
|
|
||||||
await this.voiceTurnController.appendClientChunk({
|
|
||||||
audioBase64: msg.audio,
|
|
||||||
format: msg.format || "audio/pcm;rate=16000;bits=16",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// existing non-voice path remains
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And the controller calls back into the session:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface VoiceTurnControllerCallbacks {
|
|
||||||
onSpeechStarted(): Promise<void>;
|
|
||||||
onUtteranceReady(input: {
|
|
||||||
pcm16: Buffer;
|
|
||||||
sampleRate: number;
|
|
||||||
format: string;
|
|
||||||
}): Promise<void>;
|
|
||||||
onError(error: Error): void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Session remains responsible for:
|
|
||||||
|
|
||||||
- aborting agent/TTS
|
|
||||||
- submitting transcript text to the agent
|
|
||||||
- emitting logs
|
|
||||||
|
|
||||||
The controller is responsible for:
|
|
||||||
|
|
||||||
- audio and detector state
|
|
||||||
- prefix buffering
|
|
||||||
- utterance assembly
|
|
||||||
|
|
||||||
## Client Refactor
|
|
||||||
|
|
||||||
The app should stop using `SpeechSegmenter` as authoritative segmentation in voice mode.
|
|
||||||
|
|
||||||
Instead, voice mode uses a continuous chunk sender:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ContinuousVoiceUploader {
|
|
||||||
appendPcmChunk(chunk: Uint8Array): void;
|
|
||||||
flush(): Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In `voice-runtime.ts`, the path becomes:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
handleCapturePcm(chunk) {
|
|
||||||
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
continuousUploader.appendPcmChunk(chunk);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And UI activation remains independent:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
handleCaptureVolume(level) {
|
|
||||||
const nowMs = Date.now();
|
|
||||||
publishDisplayVolume(level, nowMs);
|
|
||||||
|
|
||||||
const isActive = level > DISPLAY_ACTIVE_THRESHOLD;
|
|
||||||
patchTelemetry((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isSpeaking: isActive,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The client should no longer use a “segmenter” abstraction for voice mode. The concept is now wrong for that layer.
|
|
||||||
|
|
||||||
## Bundling Silero
|
|
||||||
|
|
||||||
Silero VAD should be treated as a bundled runtime asset, not a user-downloaded speech model.
|
|
||||||
|
|
||||||
Implications:
|
|
||||||
|
|
||||||
- not listed in `paseo speech download`
|
|
||||||
- not part of user-facing missing-model readiness
|
|
||||||
- shipped with `packages/server`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function resolveBundledSileroVadModelPath(): string {
|
|
||||||
return fileURLToPath(
|
|
||||||
new URL("./assets/silero_vad.onnx", import.meta.url)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
That keeps the model an implementation detail of local realtime voice infrastructure.
|
|
||||||
|
|
||||||
## What Not To Do In V1
|
|
||||||
|
|
||||||
- No semantic turn detection
|
|
||||||
- No mixed authorities
|
|
||||||
- No client-side fallback turn detection
|
|
||||||
- No detector logic buried inside dictation manager
|
|
||||||
- No detector-specific branching spread through session logic
|
|
||||||
|
|
||||||
If local VAD is the authority, then only local VAD decides `speech_started` and `speech_stopped`.
|
|
||||||
|
|
||||||
## Verification Plan
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
`silero-vad-session.test.ts`
|
|
||||||
|
|
||||||
- emits `speech_started` after speech frames
|
|
||||||
- emits `speech_stopped` after silence frames
|
|
||||||
- does not emit duplicate starts/stops
|
|
||||||
|
|
||||||
`fixed-duration-pcm-ring-buffer.test.ts`
|
|
||||||
|
|
||||||
- retains only configured prefix window
|
|
||||||
- drains prefix in correct order
|
|
||||||
|
|
||||||
`voice-turn-controller.test.ts`
|
|
||||||
|
|
||||||
- buffers before speech start
|
|
||||||
- includes prefix in utterance on start
|
|
||||||
- finalizes one utterance on stop
|
|
||||||
- does not barge in on silence-only chunks
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
`session.voice-turn-control.test.ts`
|
|
||||||
|
|
||||||
- continuous voice chunks do not immediately interrupt agent output
|
|
||||||
- detector start triggers barge-in
|
|
||||||
- detector stop triggers one finalized transcript submission
|
|
||||||
|
|
||||||
`voice-local-agent.e2e.test.ts`
|
|
||||||
|
|
||||||
- local voice mode works with continuous client upload
|
|
||||||
- leading words are preserved because prefix buffering is included
|
|
||||||
|
|
||||||
### Regression
|
|
||||||
|
|
||||||
- dictation stream tests remain unchanged
|
|
||||||
- TTS playback tests remain unchanged
|
|
||||||
- app voice UI tests still verify meter/activation behavior
|
|
||||||
|
|
||||||
## Ordered Implementation Plan
|
|
||||||
|
|
||||||
1. Add server-side turn detection interfaces and config type.
|
|
||||||
2. Add bundled local Silero provider via Sherpa.
|
|
||||||
3. Add rolling prefix buffer utility.
|
|
||||||
4. Add `VoiceTurnController`.
|
|
||||||
5. Refactor `session.ts` voice path to use controller callbacks instead of client `isLast`.
|
|
||||||
6. Replace app voice segmentation with continuous PCM upload.
|
|
||||||
7. Keep app meter/activation visuals only.
|
|
||||||
8. Add tests bottom-up.
|
|
||||||
9. Run `npm run typecheck`.
|
|
||||||
|
|
||||||
## Suggested Canonical Types
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface DetectedVoiceUtterance {
|
|
||||||
pcm16: Buffer;
|
|
||||||
sampleRate: number;
|
|
||||||
startedAt: number;
|
|
||||||
endedAt: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface VoiceTurnControllerCallbacks {
|
|
||||||
onSpeechStarted(): Promise<void>;
|
|
||||||
onUtteranceReady(utterance: DetectedVoiceUtterance): Promise<void>;
|
|
||||||
onError(error: Error): void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface TurnDetectionProvider {
|
|
||||||
id: "local" | "openai" | (string & {});
|
|
||||||
createSession(input: {
|
|
||||||
logger: pino.Logger;
|
|
||||||
}): TurnDetectionSession;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
For v1, optimize for correctness and clean layering rather than minimum diff size:
|
|
||||||
|
|
||||||
- continuous upload from client
|
|
||||||
- bundled local Silero detector on server
|
|
||||||
- rolling prefix buffer
|
|
||||||
- detector-owned turn boundaries
|
|
||||||
- existing STT pipeline reused as utterance sink
|
|
||||||
|
|
||||||
That keeps responsibilities clear and avoids inventing a leaky “smart STT session” abstraction.
|
|
||||||
@@ -69,7 +69,6 @@ export function DictationControls({
|
|||||||
<VolumeMeter
|
<VolumeMeter
|
||||||
volume={volume}
|
volume={volume}
|
||||||
isMuted={false}
|
isMuted={false}
|
||||||
isDetecting
|
|
||||||
isSpeaking={false}
|
isSpeaking={false}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
/>
|
/>
|
||||||
@@ -177,7 +176,6 @@ export function DictationOverlay({
|
|||||||
<VolumeMeter
|
<VolumeMeter
|
||||||
volume={volume}
|
volume={volume}
|
||||||
isMuted={false}
|
isMuted={false}
|
||||||
isDetecting
|
|
||||||
isSpeaking={false}
|
isSpeaking={false}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
color={theme.colors.palette.white}
|
color={theme.colors.palette.white}
|
||||||
|
|||||||
@@ -22,14 +22,13 @@ export function RealtimeVoiceOverlay({
|
|||||||
onStop,
|
onStop,
|
||||||
}: RealtimeVoiceOverlayProps) {
|
}: RealtimeVoiceOverlayProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.meterContainer}>
|
<View style={styles.meterContainer}>
|
||||||
<VolumeMeter
|
<VolumeMeter
|
||||||
volume={volume}
|
volume={volume}
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
isDetecting={isDetecting}
|
|
||||||
isSpeaking={isSpeaking}
|
isSpeaking={isSpeaking}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
|
|||||||
|
|
||||||
export function VoiceCompactIndicator() {
|
export function VoiceCompactIndicator() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||||
const {
|
const {
|
||||||
isVoiceMode,
|
isVoiceMode,
|
||||||
isVoiceSwitching,
|
isVoiceSwitching,
|
||||||
@@ -24,7 +24,6 @@ export function VoiceCompactIndicator() {
|
|||||||
<VolumeMeter
|
<VolumeMeter
|
||||||
volume={volume}
|
volume={volume}
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
isDetecting={isDetecting}
|
|
||||||
isSpeaking={isSpeaking}
|
isSpeaking={isSpeaking}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useHosts } from "@/runtime/host-runtime";
|
|||||||
export function VoicePanel() {
|
export function VoicePanel() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
|
const { volume, isSpeaking } = useVoiceTelemetry();
|
||||||
const {
|
const {
|
||||||
isMuted,
|
isMuted,
|
||||||
stopVoice,
|
stopVoice,
|
||||||
@@ -27,7 +27,6 @@ export function VoicePanel() {
|
|||||||
<VolumeMeter
|
<VolumeMeter
|
||||||
volume={volume}
|
volume={volume}
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
isDetecting={isDetecting}
|
|
||||||
isSpeaking={isSpeaking}
|
isSpeaking={isSpeaking}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
interface VolumeMeterProps {
|
interface VolumeMeterProps {
|
||||||
volume: number;
|
volume: number;
|
||||||
isMuted?: boolean;
|
isMuted?: boolean;
|
||||||
isDetecting?: boolean;
|
|
||||||
isSpeaking?: boolean;
|
isSpeaking?: boolean;
|
||||||
orientation?: "vertical" | "horizontal";
|
orientation?: "vertical" | "horizontal";
|
||||||
variant?: "default" | "compact";
|
variant?: "default" | "compact";
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const EMPTY_SNAPSHOT: VoiceRuntimeSnapshot = {
|
|||||||
|
|
||||||
const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
||||||
volume: 0,
|
volume: 0,
|
||||||
isDetecting: false,
|
|
||||||
isSpeaking: false,
|
isSpeaking: false,
|
||||||
segmentDuration: 0,
|
segmentDuration: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface BridgeStats {
|
|||||||
windowStartedAtMs: number;
|
windowStartedAtMs: number;
|
||||||
captureEvents: number;
|
captureEvents: number;
|
||||||
captureBytes: number;
|
captureBytes: number;
|
||||||
|
volumeEvents: number;
|
||||||
|
volumeMax: number;
|
||||||
playbackEvents: number;
|
playbackEvents: number;
|
||||||
playbackInputBytes: number;
|
playbackInputBytes: number;
|
||||||
playbackResampledBytes: number;
|
playbackResampledBytes: number;
|
||||||
@@ -88,6 +90,8 @@ export function createAudioEngine(
|
|||||||
windowStartedAtMs: Date.now(),
|
windowStartedAtMs: Date.now(),
|
||||||
captureEvents: 0,
|
captureEvents: 0,
|
||||||
captureBytes: 0,
|
captureBytes: 0,
|
||||||
|
volumeEvents: 0,
|
||||||
|
volumeMax: 0,
|
||||||
playbackEvents: 0,
|
playbackEvents: 0,
|
||||||
playbackInputBytes: 0,
|
playbackInputBytes: 0,
|
||||||
playbackResampledBytes: 0,
|
playbackResampledBytes: 0,
|
||||||
@@ -108,6 +112,7 @@ export function createAudioEngine(
|
|||||||
console.log(
|
console.log(
|
||||||
`[AudioEngine.native#${instanceId}][bridge] ${reason} ` +
|
`[AudioEngine.native#${instanceId}][bridge] ${reason} ` +
|
||||||
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
|
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
|
||||||
|
`volume=${bridgeStats.volumeEvents}ev max=${bridgeStats.volumeMax.toFixed(3)} ` +
|
||||||
`play=${bridgeStats.playbackEvents}ev/${bridgeStats.playbackInputBytes}B->${bridgeStats.playbackResampledBytes}B ` +
|
`play=${bridgeStats.playbackEvents}ev/${bridgeStats.playbackInputBytes}B->${bridgeStats.playbackResampledBytes}B ` +
|
||||||
`playMs=${bridgeStats.playbackDurationMs.toFixed(1)} ` +
|
`playMs=${bridgeStats.playbackDurationMs.toFixed(1)} ` +
|
||||||
`windowMs=${elapsedMs}`
|
`windowMs=${elapsedMs}`
|
||||||
@@ -115,6 +120,8 @@ export function createAudioEngine(
|
|||||||
bridgeStats.windowStartedAtMs = now;
|
bridgeStats.windowStartedAtMs = now;
|
||||||
bridgeStats.captureEvents = 0;
|
bridgeStats.captureEvents = 0;
|
||||||
bridgeStats.captureBytes = 0;
|
bridgeStats.captureBytes = 0;
|
||||||
|
bridgeStats.volumeEvents = 0;
|
||||||
|
bridgeStats.volumeMax = 0;
|
||||||
bridgeStats.playbackEvents = 0;
|
bridgeStats.playbackEvents = 0;
|
||||||
bridgeStats.playbackInputBytes = 0;
|
bridgeStats.playbackInputBytes = 0;
|
||||||
bridgeStats.playbackResampledBytes = 0;
|
bridgeStats.playbackResampledBytes = 0;
|
||||||
@@ -133,6 +140,8 @@ export function createAudioEngine(
|
|||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
settled: boolean;
|
settled: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
|
sawFirstMicChunk: boolean;
|
||||||
|
sawFirstVolumeEvent: boolean;
|
||||||
destroyed: boolean;
|
destroyed: boolean;
|
||||||
} = {
|
} = {
|
||||||
initialized: false,
|
initialized: false,
|
||||||
@@ -142,6 +151,8 @@ export function createAudioEngine(
|
|||||||
processingQueue: false,
|
processingQueue: false,
|
||||||
playbackTimeout: null,
|
playbackTimeout: null,
|
||||||
activePlayback: null,
|
activePlayback: null,
|
||||||
|
sawFirstMicChunk: false,
|
||||||
|
sawFirstVolumeEvent: false,
|
||||||
destroyed: false,
|
destroyed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,6 +163,12 @@ export function createAudioEngine(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pcm = event.data as Uint8Array;
|
const pcm = event.data as Uint8Array;
|
||||||
|
if (!refs.sawFirstMicChunk) {
|
||||||
|
refs.sawFirstMicChunk = true;
|
||||||
|
console.log(
|
||||||
|
`[AudioEngine.native#${instanceId}] firstMicChunk bytes=${pcm.byteLength} head=${toHexPreview(pcm)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
bridgeStats.captureEvents += 1;
|
bridgeStats.captureEvents += 1;
|
||||||
bridgeStats.captureBytes += pcm.byteLength;
|
bridgeStats.captureBytes += pcm.byteLength;
|
||||||
maybeFlushBridgeStats("capture");
|
maybeFlushBridgeStats("capture");
|
||||||
@@ -165,6 +182,15 @@ export function createAudioEngine(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const level = refs.muted ? 0 : event.data;
|
const level = refs.muted ? 0 : event.data;
|
||||||
|
bridgeStats.volumeEvents += 1;
|
||||||
|
bridgeStats.volumeMax = Math.max(bridgeStats.volumeMax, level);
|
||||||
|
if (!refs.sawFirstVolumeEvent) {
|
||||||
|
refs.sawFirstVolumeEvent = true;
|
||||||
|
console.log(
|
||||||
|
`[AudioEngine.native#${instanceId}] firstInputVolume level=${level.toFixed(3)} muted=${refs.muted}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
maybeFlushBridgeStats("volume");
|
||||||
callbacks.onVolumeLevel(level);
|
callbacks.onVolumeLevel(level);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -190,8 +216,14 @@ export function createAudioEngine(
|
|||||||
|
|
||||||
async function ensureMicrophonePermission(): Promise<void> {
|
async function ensureMicrophonePermission(): Promise<void> {
|
||||||
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
|
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
|
||||||
|
console.log(
|
||||||
|
`[AudioEngine.native#${instanceId}] microphonePermission initial=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`
|
||||||
|
);
|
||||||
if (!permission?.granted) {
|
if (!permission?.granted) {
|
||||||
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
|
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
|
||||||
|
console.log(
|
||||||
|
`[AudioEngine.native#${instanceId}] microphonePermission requested=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!permission?.granted) {
|
if (!permission?.granted) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -307,14 +339,21 @@ export function createAudioEngine(
|
|||||||
|
|
||||||
async startCapture() {
|
async startCapture() {
|
||||||
if (refs.captureActive) {
|
if (refs.captureActive) {
|
||||||
|
console.log(`[AudioEngine.native#${instanceId}] startCapture skipped: already active`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`[AudioEngine.native#${instanceId}] startCapture begin`);
|
||||||
await ensureMicrophonePermission();
|
await ensureMicrophonePermission();
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
native.toggleRecording(true);
|
refs.sawFirstMicChunk = false;
|
||||||
|
refs.sawFirstVolumeEvent = false;
|
||||||
|
const isRecording = native.toggleRecording(true);
|
||||||
refs.captureActive = true;
|
refs.captureActive = true;
|
||||||
|
console.log(
|
||||||
|
`[AudioEngine.native#${instanceId}] startCapture toggleRecording(true) => ${String(isRecording)}`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||||||
callbacks.onError?.(wrapped);
|
callbacks.onError?.(wrapped);
|
||||||
@@ -324,7 +363,10 @@ export function createAudioEngine(
|
|||||||
|
|
||||||
async stopCapture() {
|
async stopCapture() {
|
||||||
if (refs.captureActive) {
|
if (refs.captureActive) {
|
||||||
native.toggleRecording(false);
|
const isRecording = native.toggleRecording(false);
|
||||||
|
console.log(
|
||||||
|
`[AudioEngine.native#${instanceId}] stopCapture toggleRecording(false) => ${String(isRecording)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
refs.captureActive = false;
|
refs.captureActive = false;
|
||||||
refs.muted = false;
|
refs.muted = false;
|
||||||
|
|||||||
@@ -268,6 +268,40 @@ describe("voice runtime", () => {
|
|||||||
expect(engine.play).toHaveBeenCalled();
|
expect(engine.play).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not restart the thinking tone from local detection jitter while waiting", async () => {
|
||||||
|
const adapter = createSessionAdapter();
|
||||||
|
let resolvePlay!: (duration: number) => void;
|
||||||
|
const engine = createAudioEngineMock();
|
||||||
|
vi.mocked(engine.play).mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<number>((resolve) => {
|
||||||
|
resolvePlay = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { runtime } = createRuntime({ engine });
|
||||||
|
runtime.registerSession(adapter);
|
||||||
|
|
||||||
|
await runtime.startVoice("server-1", "agent-1");
|
||||||
|
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
|
||||||
|
|
||||||
|
expect(runtime.getSnapshot().phase).toBe("waiting");
|
||||||
|
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
runtime.handleCaptureVolume(REALTIME_VOICE_VAD_CONFIG.volumeThreshold + 0.05);
|
||||||
|
runtime.handleCaptureVolume(0);
|
||||||
|
|
||||||
|
expect(engine.stop).not.toHaveBeenCalled();
|
||||||
|
expect(engine.clearQueue).not.toHaveBeenCalled();
|
||||||
|
expect(engine.play).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
runtime.onServerSpeechStateChanged("server-1", true);
|
||||||
|
|
||||||
|
expect(engine.stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(engine.clearQueue).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolvePlay(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns to listening after assistant playback once the turn is complete", async () => {
|
it("returns to listening after assistant playback once the turn is complete", async () => {
|
||||||
const adapter = createSessionAdapter();
|
const adapter = createSessionAdapter();
|
||||||
const { runtime, engine } = createRuntime();
|
const { runtime, engine } = createRuntime();
|
||||||
@@ -308,7 +342,6 @@ describe("voice runtime", () => {
|
|||||||
|
|
||||||
expect(runtime.getTelemetrySnapshot()).toMatchObject({
|
expect(runtime.getTelemetrySnapshot()).toMatchObject({
|
||||||
volume: expect.any(Number),
|
volume: expect.any(Number),
|
||||||
isDetecting: true,
|
|
||||||
isSpeaking: false,
|
isSpeaking: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export interface VoiceRuntimeSnapshot {
|
|||||||
|
|
||||||
export interface VoiceRuntimeTelemetrySnapshot {
|
export interface VoiceRuntimeTelemetrySnapshot {
|
||||||
volume: number;
|
volume: number;
|
||||||
isDetecting: boolean;
|
|
||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
segmentDuration: number;
|
segmentDuration: number;
|
||||||
}
|
}
|
||||||
@@ -85,7 +84,9 @@ interface RuntimeState {
|
|||||||
generation: number;
|
generation: number;
|
||||||
segmentDurationTimer: ReturnType<typeof setInterval> | null;
|
segmentDurationTimer: ReturnType<typeof setInterval> | null;
|
||||||
lastDisplayVolumePublishMs: number;
|
lastDisplayVolumePublishMs: number;
|
||||||
localSpeechStartedAt: number | null;
|
serverSpeechStartedAt: number | null;
|
||||||
|
lastNoServerSpeechLogMs: number;
|
||||||
|
localAboveThresholdActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AudioOutputPayload = Extract<
|
type AudioOutputPayload = Extract<
|
||||||
@@ -149,7 +150,6 @@ const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
|
|||||||
|
|
||||||
const INITIAL_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
const INITIAL_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
||||||
volume: 0,
|
volume: 0,
|
||||||
isDetecting: false,
|
|
||||||
isSpeaking: false,
|
isSpeaking: false,
|
||||||
segmentDuration: 0,
|
segmentDuration: 0,
|
||||||
};
|
};
|
||||||
@@ -176,7 +176,6 @@ function telemetryEqual(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
left.volume === right.volume &&
|
left.volume === right.volume &&
|
||||||
left.isDetecting === right.isDetecting &&
|
|
||||||
left.isSpeaking === right.isSpeaking &&
|
left.isSpeaking === right.isSpeaking &&
|
||||||
left.segmentDuration === right.segmentDuration
|
left.segmentDuration === right.segmentDuration
|
||||||
);
|
);
|
||||||
@@ -219,7 +218,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
generation: 0,
|
generation: 0,
|
||||||
segmentDurationTimer: null,
|
segmentDurationTimer: null,
|
||||||
lastDisplayVolumePublishMs: 0,
|
lastDisplayVolumePublishMs: 0,
|
||||||
localSpeechStartedAt: null,
|
serverSpeechStartedAt: null,
|
||||||
|
lastNoServerSpeechLogMs: 0,
|
||||||
|
localAboveThresholdActive: false,
|
||||||
};
|
};
|
||||||
const playback: RuntimePlaybackState = {
|
const playback: RuntimePlaybackState = {
|
||||||
groups: new Map(),
|
groups: new Map(),
|
||||||
@@ -502,7 +503,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reconcileSegmentDurationTimer(): void {
|
function reconcileSegmentDurationTimer(): void {
|
||||||
if (!state.telemetry.isDetecting && !state.telemetry.isSpeaking) {
|
if (!state.serverSpeechDetected) {
|
||||||
clearSegmentDurationTimer();
|
clearSegmentDurationTimer();
|
||||||
patchTelemetry((prev) => ({ ...prev, segmentDuration: 0 }));
|
patchTelemetry((prev) => ({ ...prev, segmentDuration: 0 }));
|
||||||
return;
|
return;
|
||||||
@@ -513,7 +514,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.segmentDurationTimer = setInterval(() => {
|
state.segmentDurationTimer = setInterval(() => {
|
||||||
const startedAt = state.localSpeechStartedAt;
|
const startedAt = state.serverSpeechStartedAt;
|
||||||
patchTelemetry((prev) => ({
|
patchTelemetry((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
segmentDuration: startedAt ? Date.now() - startedAt : 0,
|
segmentDuration: startedAt ? Date.now() - startedAt : 0,
|
||||||
@@ -525,7 +526,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
return (
|
return (
|
||||||
state.snapshot.isVoiceMode &&
|
state.snapshot.isVoiceMode &&
|
||||||
state.snapshot.phase === "waiting" &&
|
state.snapshot.phase === "waiting" &&
|
||||||
!state.telemetry.isDetecting &&
|
|
||||||
!state.telemetry.isSpeaking
|
!state.telemetry.isSpeaking
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -546,7 +546,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
|
|
||||||
function resetCaptureTelemetry(): void {
|
function resetCaptureTelemetry(): void {
|
||||||
clearSegmentDurationTimer();
|
clearSegmentDurationTimer();
|
||||||
state.localSpeechStartedAt = null;
|
state.serverSpeechStartedAt = null;
|
||||||
patchTelemetry({ ...INITIAL_TELEMETRY });
|
patchTelemetry({ ...INITIAL_TELEMETRY });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,6 +621,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
state.turnInProgress = false;
|
state.turnInProgress = false;
|
||||||
state.serverSpeechDetected = false;
|
state.serverSpeechDetected = false;
|
||||||
state.lastDisplayVolumePublishMs = 0;
|
state.lastDisplayVolumePublishMs = 0;
|
||||||
|
state.lastNoServerSpeechLogMs = 0;
|
||||||
|
state.localAboveThresholdActive = false;
|
||||||
uploader.reset();
|
uploader.reset();
|
||||||
resetCaptureTelemetry();
|
resetCaptureTelemetry();
|
||||||
patchSnapshot({ ...INITIAL_SNAPSHOT });
|
patchSnapshot({ ...INITIAL_SNAPSHOT });
|
||||||
@@ -744,6 +746,11 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
|
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (bridgeStats.captureEvents === 0) {
|
||||||
|
console.log(
|
||||||
|
`[VoiceRuntime#${instanceId}] firstCapturePcm bytes=${chunk.byteLength} phase=${state.snapshot.phase} transportReady=${state.transportReady}`
|
||||||
|
);
|
||||||
|
}
|
||||||
uploader.pushPcmChunk(chunk);
|
uploader.pushPcmChunk(chunk);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -752,10 +759,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
const displayLevel = state.snapshot.isMuted ? 0 : level;
|
const displayLevel = state.snapshot.isMuted ? 0 : level;
|
||||||
publishDisplayVolume(displayLevel, nowMs);
|
publishDisplayVolume(displayLevel, nowMs);
|
||||||
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
|
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
|
||||||
state.localSpeechStartedAt = null;
|
|
||||||
patchTelemetry((prev) => ({
|
patchTelemetry((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
isDetecting: false,
|
|
||||||
isSpeaking: false,
|
isSpeaking: false,
|
||||||
segmentDuration: 0,
|
segmentDuration: 0,
|
||||||
}));
|
}));
|
||||||
@@ -763,22 +768,34 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isActive = level > REALTIME_VOICE_VAD_CONFIG.volumeThreshold;
|
const isActive = level > REALTIME_VOICE_VAD_CONFIG.volumeThreshold;
|
||||||
if (isActive && state.localSpeechStartedAt === null) {
|
if (isActive && !state.localAboveThresholdActive) {
|
||||||
state.localSpeechStartedAt = nowMs;
|
state.localAboveThresholdActive = true;
|
||||||
|
console.log(
|
||||||
|
`[VoiceRuntime#${instanceId}] localSpeechActive level=${level.toFixed(3)} threshold=${REALTIME_VOICE_VAD_CONFIG.volumeThreshold.toFixed(3)} phase=${state.snapshot.phase} transportReady=${state.transportReady}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!isActive) {
|
if (!isActive && state.localAboveThresholdActive) {
|
||||||
state.localSpeechStartedAt = null;
|
state.localAboveThresholdActive = false;
|
||||||
|
console.log(
|
||||||
|
`[VoiceRuntime#${instanceId}] localSpeechInactive phase=${state.snapshot.phase} serverSpeaking=${state.serverSpeechDetected}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isActive &&
|
||||||
|
!state.serverSpeechDetected &&
|
||||||
|
nowMs - state.lastNoServerSpeechLogMs >= 1500
|
||||||
|
) {
|
||||||
|
state.lastNoServerSpeechLogMs = nowMs;
|
||||||
|
console.log(
|
||||||
|
`[VoiceRuntime#${instanceId}] localSpeechWithoutServerSpeech level=${level.toFixed(3)} phase=${state.snapshot.phase} turnInProgress=${state.turnInProgress} transportReady=${state.transportReady}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
patchTelemetry((prev) => ({
|
patchTelemetry((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
isDetecting: isActive,
|
|
||||||
isSpeaking: state.serverSpeechDetected,
|
isSpeaking: state.serverSpeechDetected,
|
||||||
}));
|
}));
|
||||||
reconcileSegmentDurationTimer();
|
reconcileSegmentDurationTimer();
|
||||||
if (!isActive) {
|
|
||||||
patchTelemetry((prev) => ({ ...prev, segmentDuration: 0 }));
|
|
||||||
}
|
|
||||||
reconcileCue();
|
reconcileCue();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -808,7 +825,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
let group = playback.groups.get(groupId);
|
let group = playback.groups.get(groupId);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
const shouldPlay = api.shouldPlayVoiceAudio(serverId);
|
const shouldPlay = api.shouldPlayVoiceAudio(serverId);
|
||||||
console.log(`[VoiceRuntime] new group=${groupId} shouldPlay=${shouldPlay} phase=${state.snapshot.phase} isDetecting=${state.telemetry.isDetecting} isSpeaking=${state.telemetry.isSpeaking}`);
|
console.log(`[VoiceRuntime] new group=${groupId} shouldPlay=${shouldPlay} phase=${state.snapshot.phase} isSpeaking=${state.telemetry.isSpeaking}`);
|
||||||
group = {
|
group = {
|
||||||
groupId,
|
groupId,
|
||||||
isVoiceMode: payload.isVoiceMode,
|
isVoiceMode: payload.isVoiceMode,
|
||||||
@@ -1040,8 +1057,11 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[VoiceRuntime] onServerSpeechStateChanged isSpeaking=${isSpeaking} phase=${state.snapshot.phase}`);
|
console.log(
|
||||||
|
`[VoiceRuntime#${instanceId}] onServerSpeechStateChanged isSpeaking=${isSpeaking} phase=${state.snapshot.phase} volume=${state.telemetry.volume}`
|
||||||
|
);
|
||||||
state.serverSpeechDetected = isSpeaking;
|
state.serverSpeechDetected = isSpeaking;
|
||||||
|
state.serverSpeechStartedAt = isSpeaking ? (state.serverSpeechStartedAt ?? Date.now()) : null;
|
||||||
if (isSpeaking) {
|
if (isSpeaking) {
|
||||||
resetPlaybackState();
|
resetPlaybackState();
|
||||||
deps.engine.stop();
|
deps.engine.stop();
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round(PCM_BYTES_PER_MS * MIN_STREAMING_
|
|||||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/
|
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/
|
||||||
const AgentIdSchema = z.string().uuid()
|
const AgentIdSchema = z.string().uuid()
|
||||||
const VOICE_MCP_SERVER_NAME = 'paseo_voice'
|
const VOICE_MCP_SERVER_NAME = 'paseo_voice'
|
||||||
|
const VOICE_INTERRUPT_CONFIRMATION_MS = 500
|
||||||
|
|
||||||
type VoiceModeBaseConfig = {
|
type VoiceModeBaseConfig = {
|
||||||
systemPrompt?: string
|
systemPrompt?: string
|
||||||
@@ -530,10 +531,15 @@ export class Session {
|
|||||||
// Voice mode state
|
// Voice mode state
|
||||||
private isVoiceMode = false
|
private isVoiceMode = false
|
||||||
private speechInProgress = false
|
private speechInProgress = false
|
||||||
|
private pendingVoiceSpeechStartAt: number | null = null
|
||||||
|
private pendingVoiceSpeechTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
private readonly dictationStreamManager: DictationStreamManager
|
private readonly dictationStreamManager: DictationStreamManager
|
||||||
private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null
|
private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null
|
||||||
private voiceTurnController: VoiceTurnController | null = null
|
private voiceTurnController: VoiceTurnController | null = null
|
||||||
|
private voiceInputChunkCount = 0
|
||||||
|
private voiceInputBytes = 0
|
||||||
|
private voiceInputWindowStartedAt = Date.now()
|
||||||
|
|
||||||
// Audio buffering for interruption handling
|
// Audio buffering for interruption handling
|
||||||
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []
|
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []
|
||||||
@@ -2419,21 +2425,10 @@ export class Session {
|
|||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onSpeechStarted: async () => {
|
onSpeechStarted: async () => {
|
||||||
this.emit({
|
this.handleProvisionalVoiceSpeechStarted()
|
||||||
type: 'voice_input_state',
|
|
||||||
payload: {
|
|
||||||
isSpeaking: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await this.handleVoiceSpeechStart()
|
|
||||||
},
|
},
|
||||||
onSpeechStopped: async () => {
|
onSpeechStopped: async () => {
|
||||||
this.emit({
|
this.handleVoiceSpeechStopped()
|
||||||
type: 'voice_input_state',
|
|
||||||
payload: {
|
|
||||||
isSpeaking: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
this.sessionLogger.error({ err: error }, 'Voice turn controller failed')
|
this.sessionLogger.error({ err: error }, 'Voice turn controller failed')
|
||||||
@@ -2452,11 +2447,72 @@ export class Session {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearPendingVoiceSpeechStart('turn-controller-stop')
|
||||||
const controller = this.voiceTurnController
|
const controller = this.voiceTurnController
|
||||||
this.voiceTurnController = null
|
this.voiceTurnController = null
|
||||||
await controller.stop()
|
await controller.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearPendingVoiceSpeechStart(reason: string): void {
|
||||||
|
if (this.pendingVoiceSpeechTimer) {
|
||||||
|
clearTimeout(this.pendingVoiceSpeechTimer)
|
||||||
|
this.pendingVoiceSpeechTimer = null
|
||||||
|
}
|
||||||
|
if (this.pendingVoiceSpeechStartAt !== null) {
|
||||||
|
this.sessionLogger.debug({ reason }, 'Clearing provisional voice speech start')
|
||||||
|
this.pendingVoiceSpeechStartAt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleProvisionalVoiceSpeechStarted(): void {
|
||||||
|
if (this.speechInProgress || this.pendingVoiceSpeechTimer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now()
|
||||||
|
this.pendingVoiceSpeechStartAt = startedAt
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{ confirmationMs: VOICE_INTERRUPT_CONFIRMATION_MS },
|
||||||
|
'Silero VAD provisional speech_started'
|
||||||
|
)
|
||||||
|
this.pendingVoiceSpeechTimer = setTimeout(() => {
|
||||||
|
this.pendingVoiceSpeechTimer = null
|
||||||
|
if (this.pendingVoiceSpeechStartAt !== startedAt || this.speechInProgress) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingVoiceSpeechStartAt = null
|
||||||
|
this.sessionLogger.info('voice_input_state emitting isSpeaking=true')
|
||||||
|
this.emit({
|
||||||
|
type: 'voice_input_state',
|
||||||
|
payload: {
|
||||||
|
isSpeaking: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
void this.handleVoiceSpeechStart()
|
||||||
|
}, VOICE_INTERRUPT_CONFIRMATION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleVoiceSpeechStopped(): void {
|
||||||
|
if (this.pendingVoiceSpeechStartAt !== null) {
|
||||||
|
const durationMs = Date.now() - this.pendingVoiceSpeechStartAt
|
||||||
|
this.clearPendingVoiceSpeechStart('speech-stopped-before-confirmation')
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{ durationMs, confirmationMs: VOICE_INTERRUPT_CONFIRMATION_MS },
|
||||||
|
'Ignoring provisional voice speech start that ended before confirmation'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionLogger.info('voice_input_state emitting isSpeaking=false')
|
||||||
|
this.emit({
|
||||||
|
type: 'voice_input_state',
|
||||||
|
payload: {
|
||||||
|
isSpeaking: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle text message to agent (with optional image attachments)
|
* Handle text message to agent (with optional image attachments)
|
||||||
*/
|
*/
|
||||||
@@ -6317,6 +6373,36 @@ export class Session {
|
|||||||
if (!this.voiceTurnController) {
|
if (!this.voiceTurnController) {
|
||||||
throw new Error('Voice mode is enabled but the voice turn controller is not running')
|
throw new Error('Voice mode is enabled but the voice turn controller is not running')
|
||||||
}
|
}
|
||||||
|
const chunkBytes = Buffer.byteLength(msg.audio, 'base64')
|
||||||
|
this.voiceInputChunkCount += 1
|
||||||
|
this.voiceInputBytes += chunkBytes
|
||||||
|
if (this.voiceInputChunkCount === 1) {
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{
|
||||||
|
format: chunkFormat,
|
||||||
|
audioBytes: chunkBytes,
|
||||||
|
},
|
||||||
|
'Received first voice_audio_chunk for active voice mode'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
if (
|
||||||
|
this.voiceInputChunkCount % 50 === 0 ||
|
||||||
|
now - this.voiceInputWindowStartedAt >= 1000
|
||||||
|
) {
|
||||||
|
this.sessionLogger.info(
|
||||||
|
{
|
||||||
|
chunkCount: this.voiceInputChunkCount,
|
||||||
|
audioBytes: this.voiceInputBytes,
|
||||||
|
windowMs: now - this.voiceInputWindowStartedAt,
|
||||||
|
format: chunkFormat,
|
||||||
|
},
|
||||||
|
'Voice input chunk summary'
|
||||||
|
)
|
||||||
|
this.voiceInputWindowStartedAt = now
|
||||||
|
this.voiceInputChunkCount = 0
|
||||||
|
this.voiceInputBytes = 0
|
||||||
|
}
|
||||||
await this.voiceTurnController.appendClientChunk({
|
await this.voiceTurnController.appendClientChunk({
|
||||||
audioBase64: msg.audio,
|
audioBase64: msg.audio,
|
||||||
format: chunkFormat,
|
format: chunkFormat,
|
||||||
@@ -6772,6 +6858,7 @@ export class Session {
|
|||||||
* Clear speech-in-progress flag once the user turn has completed
|
* Clear speech-in-progress flag once the user turn has completed
|
||||||
*/
|
*/
|
||||||
private clearSpeechInProgress(reason: string): void {
|
private clearSpeechInProgress(reason: string): void {
|
||||||
|
this.clearPendingVoiceSpeechStart(`clear-speech-in-progress:${reason}`)
|
||||||
if (!this.speechInProgress) {
|
if (!this.speechInProgress) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const DEFAULT_SAMPLE_RATE = 16000;
|
|||||||
const DEFAULT_BUFFER_SIZE_SECONDS = 60;
|
const DEFAULT_BUFFER_SIZE_SECONDS = 60;
|
||||||
const DEFAULT_SILERO_THRESHOLD = 0.5;
|
const DEFAULT_SILERO_THRESHOLD = 0.5;
|
||||||
const DEFAULT_MIN_SILENCE_DURATION = 1.2;
|
const DEFAULT_MIN_SILENCE_DURATION = 1.2;
|
||||||
const DEFAULT_MIN_SPEECH_DURATION = 0.12;
|
const DEFAULT_MIN_SPEECH_DURATION = 0.2;
|
||||||
const DEFAULT_WINDOW_SIZE = 512;
|
const DEFAULT_WINDOW_SIZE = 512;
|
||||||
|
|
||||||
type SherpaVadHandle = {
|
type SherpaVadHandle = {
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
# Desktop Startup Refactor Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
1. Tauri runs `paseo start`, then polls `paseo daemon status --json` until ready
|
|
||||||
2. `daemon status --json` returns ALL needed data: serverId, listen address, hostname, status, pid
|
|
||||||
3. App takes that data and adds it to the registry like any other host
|
|
||||||
4. App blocks on startup splash until any host runtime goes online (race)
|
|
||||||
5. 30s timeout → redirect to welcome/add-connection screen
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
|
|
||||||
- The daemon is autonomous. Tauri does not pass `--home`, `--listen`, or read any files.
|
|
||||||
- `daemon status --json` is the single source of truth. All data comes from it.
|
|
||||||
- The host registry has no concept of "managed" daemons. A host is just a serverId + connections.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Add serverId to `daemon status --json`
|
|
||||||
|
|
||||||
**File: `packages/cli/src/commands/daemon/status.ts`**
|
|
||||||
|
|
||||||
`DaemonStatus` currently has: `status`, `home`, `listen`, `pid`, `startedAt`, `owner`, `logPath`, etc.
|
|
||||||
|
|
||||||
Add:
|
|
||||||
- `serverId: string | null`
|
|
||||||
- `hostname: string | null` (already available from pidInfo, just not in the output)
|
|
||||||
|
|
||||||
The status command already calls `resolveLocalDaemonState()` which reads the pid file (has hostname) and resolves home. From home it can call `getOrCreateServerId(home)` to get the serverId. All internal to the status command — Tauri never touches these files.
|
|
||||||
|
|
||||||
### Phase 2: Tauri — just start + poll status
|
|
||||||
|
|
||||||
**File: `packages/desktop/src-tauri/src/runtime_manager.rs`**
|
|
||||||
|
|
||||||
#### 2a. `start_managed_daemon_internal` becomes:
|
|
||||||
|
|
||||||
1. Run `paseo start` (no `--home`, no `--listen`)
|
|
||||||
2. Poll `paseo daemon status --json` in a loop until `status == "running"` and `serverId` is present
|
|
||||||
3. Return the parsed JSON to the app
|
|
||||||
|
|
||||||
#### 2b. Remove all daemon plumbing from Rust
|
|
||||||
|
|
||||||
Delete:
|
|
||||||
- `managed_transport_target()`, `ManagedTransportTarget`
|
|
||||||
- `default_transport_path()`, `default_transport_type()`
|
|
||||||
- `default_managed_home()`
|
|
||||||
- `resolve_paths()` / `ManagedPaths`
|
|
||||||
- `read_server_id()`, `read_hostname_from_home()`, `read_pid_from_home()`
|
|
||||||
- `cli_env()` — no `PASEO_HOME` or `PASEO_HOST` injection
|
|
||||||
- State file reading/writing for transport config
|
|
||||||
- All direct file I/O for daemon state
|
|
||||||
|
|
||||||
Keep:
|
|
||||||
- `bundled_runtime_root()` — to find bundled Node.js + CLI
|
|
||||||
- `cli_command()` — to invoke the bundled CLI
|
|
||||||
- `ensure_runtime_ready_internal()` — to verify runtime extraction
|
|
||||||
|
|
||||||
#### 2c. `ManagedDaemonStatus` mirrors `daemon status --json`
|
|
||||||
|
|
||||||
- `serverId: string`
|
|
||||||
- `status: string`
|
|
||||||
- `listen: string`
|
|
||||||
- `hostname: string | null`
|
|
||||||
- `pid: number | null`
|
|
||||||
- `home: string`
|
|
||||||
- Plus: `runtimeId`, `runtimeVersion` (from bundled runtime manifest)
|
|
||||||
|
|
||||||
App parses `listen` to determine transport type:
|
|
||||||
- `\\.\pipe\*` or `pipe://` → pipe
|
|
||||||
- `/` or `unix://` → socket
|
|
||||||
- `host:port` → tcp
|
|
||||||
|
|
||||||
### Phase 3: App — remove "managed" concept from registry
|
|
||||||
|
|
||||||
**File: `packages/app/src/contexts/daemon-registry-context.tsx`**
|
|
||||||
|
|
||||||
#### 3a. Delete managed-specific code
|
|
||||||
|
|
||||||
- `ManagedHostReconciliationInput`
|
|
||||||
- `DesktopStartupReconciliationInput`
|
|
||||||
- `reconcileManagedHostInProfiles()`
|
|
||||||
- `reconcileDesktopStartupRegistry()`
|
|
||||||
- `probeManagedConnectionUntilReady()`
|
|
||||||
- `probeManagedStartupTarget()`
|
|
||||||
- `resolveManagedDesktopStartupStatus()`
|
|
||||||
- `DEFAULT_LOCAL_TRANSPORT_BOOTSTRAP_*` constants
|
|
||||||
|
|
||||||
#### 3b. Remove `managed` from `HostLifecycle`
|
|
||||||
|
|
||||||
Remove `managed`, `managedRuntimeId`, `managedRuntimeVersion`, `associatedServerId`. Remove all code that checks `lifecycle.managed`.
|
|
||||||
|
|
||||||
#### 3c. Desktop startup effect becomes:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const bootstrap = async () => {
|
|
||||||
const daemon = await startManagedDaemon()
|
|
||||||
if (cancelled || !daemon.serverId) return
|
|
||||||
|
|
||||||
const connection = connectionFromListen(daemon.listen)
|
|
||||||
if (!connection) return
|
|
||||||
|
|
||||||
await upsertHostConnection({
|
|
||||||
serverId: daemon.serverId,
|
|
||||||
label: daemon.hostname ?? undefined,
|
|
||||||
connection,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3d. Remove `isReconciling`
|
|
||||||
|
|
||||||
No longer needed. Registry update triggers `MultiDaemonSessionHost` → `syncHosts()` → `HostRuntimeController` connects.
|
|
||||||
|
|
||||||
### Phase 4: Index screen — race until online
|
|
||||||
|
|
||||||
**File: `packages/app/src/app/index.tsx`**
|
|
||||||
|
|
||||||
- New hook: `useAnyHostOnline(serverIds)` — subscribes to `HostRuntimeStore`
|
|
||||||
- Show splash while: registry loading OR (hosts exist, none online, within 30s)
|
|
||||||
- Any host goes online → navigate to it
|
|
||||||
- 30s timeout → WelcomeScreen
|
|
||||||
|
|
||||||
Both managed daemon and localhost:6767 race. First to connect wins.
|
|
||||||
|
|
||||||
### Phase 5: Localhost bootstrap
|
|
||||||
|
|
||||||
Non-desktop: keep the lightweight probe (2.5s, needs serverId from handshake).
|
|
||||||
Desktop: localhost:6767 added as a race participant alongside the daemon connection.
|
|
||||||
|
|
||||||
### Phase 6: Metro must ignore app test files
|
|
||||||
|
|
||||||
**Files: `packages/app/metro.config.js` and any directly related Metro resolver helpers/tests**
|
|
||||||
|
|
||||||
The app already contains many `*.test.ts` and `*.test.tsx` files under `packages/app/src`. Those files must remain in the repo and must NOT be deleted to make Metro happy.
|
|
||||||
|
|
||||||
Implement Metro-side protection so Expo/Metro does not try to bundle app test files from `packages/app/src` during normal app/web startup:
|
|
||||||
|
|
||||||
- Treat `*.test.ts`, `*.test.tsx`, `*.spec.ts`, and `*.spec.tsx` under `packages/app/src` as ignored/non-bundle inputs for Metro app resolution
|
|
||||||
- If the current Metro resolver hook needs to special-case local imports or extensions, update it so test files are skipped cleanly instead of causing bundle resolution crashes
|
|
||||||
- Preserve Vitest usage for those files; the change is only about Metro bundling behavior, not removing or renaming tests
|
|
||||||
- If a test index/helper file is needed for app tests, keep it and make Metro ignore it rather than deleting it
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What stays the same
|
|
||||||
|
|
||||||
- `HostRuntimeController`, `HostRuntimeStore`, `MultiDaemonSessionHost` — no changes
|
|
||||||
- `DaemonClient` transports — no changes
|
|
||||||
- The daemon itself — no changes (already autonomous)
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. `paseo daemon status --json` returns `serverId`, `listen`, `hostname`, `status`, and `pid`, and desktop startup consumes that shape as the source of truth
|
|
||||||
2. The app registry no longer contains managed-daemon lifecycle concepts; desktop startup upserts the managed daemon like any other host
|
|
||||||
3. Desktop startup blocks on the startup splash during the connection race and only leaves it when a host goes online or the 30s timeout expires
|
|
||||||
4. Desktop still races the managed daemon and `localhost:6767`; non-desktop keeps the lightweight localhost probe
|
|
||||||
5. Metro does not try to bundle `packages/app/src/**/*.test.*` or `packages/app/src/**/*.spec.*` during normal app startup
|
|
||||||
6. Existing app test files, including any `index.test.ts` helper needed by tests, remain in the repo and are not deleted as a workaround
|
|
||||||
7. `npm run typecheck` passes
|
|
||||||
Reference in New Issue
Block a user