Configure STT language from settings (#941)
* Configure STT language from settings * Update websocket speech mock for language config
This commit is contained in:
+2
-2
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
|
|||||||
providers: Record<providerId, ProviderOverride>
|
providers: Record<providerId, ProviderOverride>
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||||
},
|
},
|
||||||
log: {
|
log: {
|
||||||
level, format,
|
level, format,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import pino from "pino";
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
import { STTManager } from "./stt-manager.js";
|
import { STTManager } from "./stt-manager.js";
|
||||||
|
import { PersistedConfigSchema } from "../persisted-config.js";
|
||||||
|
import { resolveSpeechConfig } from "../speech/speech-config-resolver.js";
|
||||||
import type {
|
import type {
|
||||||
SpeechToTextProvider,
|
SpeechToTextProvider,
|
||||||
StreamingTranscriptionSession,
|
StreamingTranscriptionSession,
|
||||||
@@ -16,9 +18,11 @@ type StreamingOnHandler = Parameters<StreamingOn>[1];
|
|||||||
|
|
||||||
class FakeStt implements SpeechToTextProvider {
|
class FakeStt implements SpeechToTextProvider {
|
||||||
public readonly id = "fake";
|
public readonly id = "fake";
|
||||||
|
public lastLanguage?: string;
|
||||||
constructor(private readonly result: TranscriptionResult) {}
|
constructor(private readonly result: TranscriptionResult) {}
|
||||||
|
|
||||||
createSession(_params: SessionParams): StreamingTranscriptionSession {
|
createSession(params: SessionParams): StreamingTranscriptionSession {
|
||||||
|
this.lastLanguage = params.language;
|
||||||
const emitter = new EventEmitter();
|
const emitter = new EventEmitter();
|
||||||
const result = this.result;
|
const result = this.result;
|
||||||
let segmentId = "seg-1";
|
let segmentId = "seg-1";
|
||||||
@@ -92,6 +96,116 @@ class SequencedFakeStt implements SpeechToTextProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("STTManager", () => {
|
describe("STTManager", () => {
|
||||||
|
function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string {
|
||||||
|
const result = resolveSpeechConfig({
|
||||||
|
paseoHome: "/tmp/paseo-home",
|
||||||
|
env: params.env ?? ({} as NodeJS.ProcessEnv),
|
||||||
|
persisted: PersistedConfigSchema.parse(params.persisted ?? {}),
|
||||||
|
});
|
||||||
|
return result.speech.sttLanguages.voice;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcribeWithResolvedVoiceLanguage(params: {
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
persisted?: unknown;
|
||||||
|
}): Promise<FakeStt> {
|
||||||
|
const fakeStt = new FakeStt({ text: "hi", isLowConfidence: false });
|
||||||
|
const manager = new STTManager("s1", pino({ level: "silent" }), fakeStt, {
|
||||||
|
language: resolveVoiceLanguage(params),
|
||||||
|
});
|
||||||
|
await manager.transcribe(Buffer.alloc(2), "audio/pcm;rate=24000");
|
||||||
|
return fakeStt;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("defaults to English when no voice language config is set", async () => {
|
||||||
|
const fakeStt = await transcribeWithResolvedVoiceLanguage({});
|
||||||
|
|
||||||
|
expect(fakeStt.lastLanguage).toBe("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses PASEO_VOICE_LANGUAGE over PASEO_DICTATION_LANGUAGE", async () => {
|
||||||
|
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||||
|
env: {
|
||||||
|
PASEO_VOICE_LANGUAGE: "pt",
|
||||||
|
PASEO_DICTATION_LANGUAGE: "es",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fakeStt.lastLanguage).toBe("pt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses PASEO_DICTATION_LANGUAGE when PASEO_VOICE_LANGUAGE is unset", async () => {
|
||||||
|
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||||
|
env: {
|
||||||
|
PASEO_DICTATION_LANGUAGE: "pt",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fakeStt.lastLanguage).toBe("pt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats empty voice language env vars as unset", async () => {
|
||||||
|
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||||
|
env: {
|
||||||
|
PASEO_VOICE_LANGUAGE: "",
|
||||||
|
PASEO_DICTATION_LANGUAGE: " ",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fakeStt.lastLanguage).toBe("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses settings voice STT language when no env var is set", async () => {
|
||||||
|
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||||
|
persisted: {
|
||||||
|
features: {
|
||||||
|
voiceMode: {
|
||||||
|
stt: {
|
||||||
|
language: "fr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fakeStt.lastLanguage).toBe("fr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses env voice language over settings voice STT language", async () => {
|
||||||
|
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||||
|
env: {
|
||||||
|
PASEO_VOICE_LANGUAGE: "pt",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
persisted: {
|
||||||
|
features: {
|
||||||
|
voiceMode: {
|
||||||
|
stt: {
|
||||||
|
language: "fr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fakeStt.lastLanguage).toBe("pt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to settings dictation STT language when voice language is unset", async () => {
|
||||||
|
const fakeStt = await transcribeWithResolvedVoiceLanguage({
|
||||||
|
persisted: {
|
||||||
|
features: {
|
||||||
|
dictation: {
|
||||||
|
stt: {
|
||||||
|
language: "es",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fakeStt.lastLanguage).toBe("es");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns empty text for low-confidence transcriptions", async () => {
|
it("returns empty text for low-confidence transcriptions", async () => {
|
||||||
const manager = new STTManager(
|
const manager = new STTManager(
|
||||||
"s1",
|
"s1",
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ export interface SessionTranscriptionResult extends TranscriptionResult {
|
|||||||
format: string;
|
format: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface STTManagerOptions {
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-session STT manager
|
* Per-session STT manager
|
||||||
* Handles speech-to-text transcription
|
* Handles speech-to-text transcription
|
||||||
@@ -119,15 +123,18 @@ export class STTManager {
|
|||||||
private readonly sessionId: string;
|
private readonly sessionId: string;
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
private readonly resolveStt: () => SpeechToTextProvider | null;
|
private readonly resolveStt: () => SpeechToTextProvider | null;
|
||||||
|
private readonly language: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
logger: pino.Logger,
|
logger: pino.Logger,
|
||||||
stt: Resolvable<SpeechToTextProvider | null>,
|
stt: Resolvable<SpeechToTextProvider | null>,
|
||||||
|
options?: STTManagerOptions,
|
||||||
) {
|
) {
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
this.logger = logger.child({ module: "agent", component: "stt-manager", sessionId });
|
this.logger = logger.child({ module: "agent", component: "stt-manager", sessionId });
|
||||||
this.resolveStt = toResolver(stt);
|
this.resolveStt = toResolver(stt);
|
||||||
|
this.language = options?.language ?? "en";
|
||||||
}
|
}
|
||||||
|
|
||||||
public getProvider(): SpeechToTextProvider | null {
|
public getProvider(): SpeechToTextProvider | null {
|
||||||
@@ -171,7 +178,7 @@ export class STTManager {
|
|||||||
|
|
||||||
const session = stt.createSession({
|
const session = stt.createSession({
|
||||||
logger: this.logger.child({ component: "stt-session" }),
|
logger: this.logger.child({ component: "stt-session" }),
|
||||||
language: "en",
|
language: this.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pcmForModel = preparePcmForModel(audio, format, session.requiredSampleRate);
|
const pcmForModel = preparePcmForModel(audio, format, session.requiredSampleRate);
|
||||||
|
|||||||
@@ -195,8 +195,14 @@ function summarizeAgentMcpDebugBody(body: unknown): Record<string, unknown> {
|
|||||||
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
|
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
|
||||||
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
|
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
|
||||||
|
|
||||||
|
export interface PaseoSpeechSttLanguages {
|
||||||
|
dictation: string;
|
||||||
|
voice: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaseoSpeechConfig {
|
export interface PaseoSpeechConfig {
|
||||||
providers: RequestedSpeechProviders;
|
providers: RequestedSpeechProviders;
|
||||||
|
sttLanguages?: PaseoSpeechSttLanguages;
|
||||||
local?: PaseoLocalSpeechConfig;
|
local?: PaseoLocalSpeechConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { EventEmitter } from "node:events";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import { DictationStreamManager } from "./dictation-stream-manager.js";
|
import { DictationStreamManager } from "./dictation-stream-manager.js";
|
||||||
|
import { PersistedConfigSchema } from "../persisted-config.js";
|
||||||
|
import { resolveSpeechConfig } from "../speech/speech-config-resolver.js";
|
||||||
import type {
|
import type {
|
||||||
SpeechToTextProvider,
|
SpeechToTextProvider,
|
||||||
StreamingTranscriptionSession,
|
StreamingTranscriptionSession,
|
||||||
@@ -51,10 +53,12 @@ class FakeRealtimeSession extends EventEmitter implements StreamingTranscription
|
|||||||
|
|
||||||
class FakeSttProvider implements SpeechToTextProvider {
|
class FakeSttProvider implements SpeechToTextProvider {
|
||||||
public readonly id = "fake";
|
public readonly id = "fake";
|
||||||
|
public lastLanguage?: string;
|
||||||
constructor(private readonly session: FakeRealtimeSession) {}
|
constructor(private readonly session: FakeRealtimeSession) {}
|
||||||
createSession(
|
createSession(
|
||||||
_params: Parameters<SpeechToTextProvider["createSession"]>[0],
|
params: Parameters<SpeechToTextProvider["createSession"]>[0],
|
||||||
): StreamingTranscriptionSession {
|
): StreamingTranscriptionSession {
|
||||||
|
this.lastLanguage = params.language;
|
||||||
return this.session;
|
return this.session;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +127,97 @@ describe("DictationStreamManager (finish buffer-too-small tolerance)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("DictationStreamManager (provider-agnostic provider)", () => {
|
describe("DictationStreamManager (provider-agnostic provider)", () => {
|
||||||
|
function resolveDictationLanguage(params: {
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
persisted?: unknown;
|
||||||
|
}): string {
|
||||||
|
const result = resolveSpeechConfig({
|
||||||
|
paseoHome: "/tmp/paseo-home",
|
||||||
|
env: params.env ?? ({} as NodeJS.ProcessEnv),
|
||||||
|
persisted: PersistedConfigSchema.parse(params.persisted ?? {}),
|
||||||
|
});
|
||||||
|
return result.speech.sttLanguages.dictation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWithResolvedDictationLanguage(params: {
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
persisted?: unknown;
|
||||||
|
}): Promise<FakeSttProvider> {
|
||||||
|
const session = new FakeRealtimeSession();
|
||||||
|
const sttProvider = new FakeSttProvider(session);
|
||||||
|
const manager = new DictationStreamManager({
|
||||||
|
logger: pino({ level: "silent" }),
|
||||||
|
emit: () => {},
|
||||||
|
sessionId: "s1",
|
||||||
|
stt: sttProvider,
|
||||||
|
language: resolveDictationLanguage(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.handleStart("d-lang", "audio/pcm;rate=24000;bits=16");
|
||||||
|
return sttProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("defaults to English when dictation language config is unset", async () => {
|
||||||
|
const sttProvider = await startWithResolvedDictationLanguage({});
|
||||||
|
|
||||||
|
expect(sttProvider.lastLanguage).toBe("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses PASEO_DICTATION_LANGUAGE when set", async () => {
|
||||||
|
const sttProvider = await startWithResolvedDictationLanguage({
|
||||||
|
env: {
|
||||||
|
PASEO_DICTATION_LANGUAGE: "pt",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sttProvider.lastLanguage).toBe("pt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats empty PASEO_DICTATION_LANGUAGE as unset", async () => {
|
||||||
|
const sttProvider = await startWithResolvedDictationLanguage({
|
||||||
|
env: {
|
||||||
|
PASEO_DICTATION_LANGUAGE: " ",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sttProvider.lastLanguage).toBe("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses settings dictation STT language when env var is unset", async () => {
|
||||||
|
const sttProvider = await startWithResolvedDictationLanguage({
|
||||||
|
persisted: {
|
||||||
|
features: {
|
||||||
|
dictation: {
|
||||||
|
stt: {
|
||||||
|
language: "fr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sttProvider.lastLanguage).toBe("fr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses env dictation language over settings dictation STT language", async () => {
|
||||||
|
const sttProvider = await startWithResolvedDictationLanguage({
|
||||||
|
env: {
|
||||||
|
PASEO_DICTATION_LANGUAGE: "pt",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
persisted: {
|
||||||
|
features: {
|
||||||
|
dictation: {
|
||||||
|
stt: {
|
||||||
|
language: "fr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sttProvider.lastLanguage).toBe("pt");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not require OPENAI_API_KEY", async () => {
|
it("does not require OPENAI_API_KEY", async () => {
|
||||||
const original = process.env.OPENAI_API_KEY;
|
const original = process.env.OPENAI_API_KEY;
|
||||||
delete process.env.OPENAI_API_KEY;
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export class DictationStreamManager {
|
|||||||
private readonly emit: (msg: DictationStreamOutboundMessage) => void;
|
private readonly emit: (msg: DictationStreamOutboundMessage) => void;
|
||||||
private readonly sessionId: string;
|
private readonly sessionId: string;
|
||||||
private readonly resolveStt: () => SpeechToTextProvider | null;
|
private readonly resolveStt: () => SpeechToTextProvider | null;
|
||||||
|
private readonly language: string;
|
||||||
private readonly finalTimeoutMs: number;
|
private readonly finalTimeoutMs: number;
|
||||||
private readonly autoCommitSeconds: number;
|
private readonly autoCommitSeconds: number;
|
||||||
private readonly streams = new Map<string, DictationStreamState>();
|
private readonly streams = new Map<string, DictationStreamState>();
|
||||||
@@ -139,6 +140,7 @@ export class DictationStreamManager {
|
|||||||
emit: (msg: DictationStreamOutboundMessage) => void;
|
emit: (msg: DictationStreamOutboundMessage) => void;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
stt: Resolvable<SpeechToTextProvider | null>;
|
stt: Resolvable<SpeechToTextProvider | null>;
|
||||||
|
language?: string;
|
||||||
finalTimeoutMs?: number;
|
finalTimeoutMs?: number;
|
||||||
autoCommitSeconds?: number;
|
autoCommitSeconds?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -146,6 +148,7 @@ export class DictationStreamManager {
|
|||||||
this.emit = params.emit;
|
this.emit = params.emit;
|
||||||
this.sessionId = params.sessionId;
|
this.sessionId = params.sessionId;
|
||||||
this.resolveStt = toResolver(params.stt);
|
this.resolveStt = toResolver(params.stt);
|
||||||
|
this.language = params.language ?? "en";
|
||||||
this.finalTimeoutMs = params.finalTimeoutMs ?? DEFAULT_DICTATION_FINAL_TIMEOUT_MS;
|
this.finalTimeoutMs = params.finalTimeoutMs ?? DEFAULT_DICTATION_FINAL_TIMEOUT_MS;
|
||||||
this.autoCommitSeconds =
|
this.autoCommitSeconds =
|
||||||
params.autoCommitSeconds ??
|
params.autoCommitSeconds ??
|
||||||
@@ -176,7 +179,7 @@ export class DictationStreamManager {
|
|||||||
try {
|
try {
|
||||||
stt = sttProvider.createSession({
|
stt = sttProvider.createSession({
|
||||||
logger: this.logger.child({ dictationId }),
|
logger: this.logger.child({ dictationId }),
|
||||||
language: "en",
|
language: this.language,
|
||||||
prompt: transcriptionPrompt,
|
prompt: transcriptionPrompt,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -483,6 +483,26 @@ describe("PersistedConfigSchema voice mode config", () => {
|
|||||||
|
|
||||||
expect(parsed.features?.voiceMode?.turnDetection?.provider).toBe("local");
|
expect(parsed.features?.voiceMode?.turnDetection?.provider).toBe("local");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts trimmed STT language fields", () => {
|
||||||
|
const parsed = PersistedConfigSchema.parse({
|
||||||
|
features: {
|
||||||
|
dictation: {
|
||||||
|
stt: {
|
||||||
|
language: " fr ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
voiceMode: {
|
||||||
|
stt: {
|
||||||
|
language: " de ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.features?.dictation?.stt?.language).toBe("fr");
|
||||||
|
expect(parsed.features?.voiceMode?.stt?.language).toBe("de");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
|
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const FeatureDictationSchema = z
|
|||||||
.object({
|
.object({
|
||||||
provider: SpeechProviderIdSchema.optional(),
|
provider: SpeechProviderIdSchema.optional(),
|
||||||
model: z.string().min(1).optional(),
|
model: z.string().min(1).optional(),
|
||||||
|
language: z.string().trim().min(1).optional(),
|
||||||
confidenceThreshold: z.number().optional(),
|
confidenceThreshold: z.number().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -107,6 +108,7 @@ const FeatureVoiceModeSchema = z
|
|||||||
.object({
|
.object({
|
||||||
provider: SpeechProviderIdSchema.optional(),
|
provider: SpeechProviderIdSchema.optional(),
|
||||||
model: z.string().min(1).optional(),
|
model: z.string().min(1).optional(),
|
||||||
|
language: z.string().trim().min(1).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ export interface SessionOptions {
|
|||||||
daemonConfigStore: DaemonConfigStore;
|
daemonConfigStore: DaemonConfigStore;
|
||||||
mcpBaseUrl?: string | null;
|
mcpBaseUrl?: string | null;
|
||||||
stt: Resolvable<SpeechToTextProvider | null>;
|
stt: Resolvable<SpeechToTextProvider | null>;
|
||||||
|
sttLanguage?: string;
|
||||||
tts: Resolvable<TextToSpeechProvider | null>;
|
tts: Resolvable<TextToSpeechProvider | null>;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
providerSnapshotManager?: ProviderSnapshotManager;
|
providerSnapshotManager?: ProviderSnapshotManager;
|
||||||
@@ -572,6 +573,7 @@ export interface SessionOptions {
|
|||||||
dictation?: {
|
dictation?: {
|
||||||
finalTimeoutMs?: number;
|
finalTimeoutMs?: number;
|
||||||
stt?: Resolvable<SpeechToTextProvider | null>;
|
stt?: Resolvable<SpeechToTextProvider | null>;
|
||||||
|
sttLanguage?: string;
|
||||||
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||||
};
|
};
|
||||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
|
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||||
@@ -783,6 +785,7 @@ export class Session {
|
|||||||
private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
|
private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
|
||||||
private unregisterVoiceCallerContext?: (agentId: string) => void;
|
private unregisterVoiceCallerContext?: (agentId: string) => void;
|
||||||
private getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
private getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||||
|
private readonly sttLanguage: string;
|
||||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||||
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
|
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
|
||||||
private readonly isDev: boolean;
|
private readonly isDev: boolean;
|
||||||
@@ -814,6 +817,7 @@ export class Session {
|
|||||||
daemonConfigStore,
|
daemonConfigStore,
|
||||||
mcpBaseUrl,
|
mcpBaseUrl,
|
||||||
stt,
|
stt,
|
||||||
|
sttLanguage,
|
||||||
tts,
|
tts,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
providerSnapshotManager,
|
providerSnapshotManager,
|
||||||
@@ -875,6 +879,7 @@ export class Session {
|
|||||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||||
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||||
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
||||||
|
this.sttLanguage = sttLanguage ?? "en";
|
||||||
this.subscribeToOptionalManagers();
|
this.subscribeToOptionalManagers();
|
||||||
this.bindVoiceBridges({ voice, voiceBridge, dictation });
|
this.bindVoiceBridges({ voice, voiceBridge, dictation });
|
||||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||||
@@ -890,7 +895,7 @@ export class Session {
|
|||||||
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.initializePerSessionManagers({ tts, stt, dictation });
|
this.initializePerSessionManagers({ tts, stt, sttLanguage, dictation });
|
||||||
|
|
||||||
// Initialize agent MCP client asynchronously
|
// Initialize agent MCP client asynchronously
|
||||||
void this.initializeAgentMcp();
|
void this.initializeAgentMcp();
|
||||||
@@ -1191,16 +1196,20 @@ export class Session {
|
|||||||
private initializePerSessionManagers(params: {
|
private initializePerSessionManagers(params: {
|
||||||
tts: SessionOptions["tts"];
|
tts: SessionOptions["tts"];
|
||||||
stt: SessionOptions["stt"];
|
stt: SessionOptions["stt"];
|
||||||
|
sttLanguage: SessionOptions["sttLanguage"];
|
||||||
dictation: SessionOptions["dictation"];
|
dictation: SessionOptions["dictation"];
|
||||||
}): void {
|
}): void {
|
||||||
const { tts, stt, dictation } = params;
|
const { tts, stt, sttLanguage, dictation } = params;
|
||||||
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
|
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
|
||||||
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt);
|
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt, {
|
||||||
|
language: sttLanguage,
|
||||||
|
});
|
||||||
this.dictationStreamManager = new DictationStreamManager({
|
this.dictationStreamManager = new DictationStreamManager({
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
emit: (msg) => this.handleDictationManagerMessage(msg),
|
emit: (msg) => this.handleDictationManagerMessage(msg),
|
||||||
stt: dictation?.stt ?? null,
|
stt: dictation?.stt ?? null,
|
||||||
|
language: dictation?.sttLanguage,
|
||||||
finalTimeoutMs: dictation?.finalTimeoutMs,
|
finalTimeoutMs: dictation?.finalTimeoutMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2765,6 +2774,7 @@ export class Session {
|
|||||||
logger: this.sessionLogger.child({ component: "voice-turn-controller" }),
|
logger: this.sessionLogger.child({ component: "voice-turn-controller" }),
|
||||||
turnDetection,
|
turnDetection,
|
||||||
stt,
|
stt,
|
||||||
|
sttLanguage: this.sttLanguage,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onSpeechStarted: async () => {
|
onSpeechStarted: async () => {
|
||||||
this.sessionLogger.debug("Voice VAD speech_started");
|
this.sessionLogger.debug("Voice VAD speech_started");
|
||||||
|
|||||||
@@ -29,13 +29,21 @@ export interface LocalSpeechProviderConfig {
|
|||||||
|
|
||||||
export interface ResolvedLocalSpeechConfig {
|
export interface ResolvedLocalSpeechConfig {
|
||||||
local: LocalSpeechProviderConfig | undefined;
|
local: LocalSpeechProviderConfig | undefined;
|
||||||
|
sttLanguages: LocalSpeechSttLanguageConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId };
|
export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId };
|
||||||
|
|
||||||
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
|
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
|
||||||
|
const DEFAULT_STT_LANGUAGE = "en";
|
||||||
|
|
||||||
|
export interface LocalSpeechSttLanguageConfig {
|
||||||
|
dictation: string;
|
||||||
|
voice: string;
|
||||||
|
}
|
||||||
|
|
||||||
const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]);
|
const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]);
|
||||||
|
const LanguageSchema = z.string().trim().min(1).default(DEFAULT_STT_LANGUAGE);
|
||||||
|
|
||||||
const OptionalFiniteNumberSchema = NumberLikeSchema.pipe(z.coerce.number().finite()).optional();
|
const OptionalFiniteNumberSchema = NumberLikeSchema.pipe(z.coerce.number().finite()).optional();
|
||||||
|
|
||||||
@@ -47,6 +55,8 @@ const LocalSpeechResolutionSchema = z.object({
|
|||||||
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||||
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||||
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
|
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
|
||||||
|
dictationLanguage: LanguageSchema,
|
||||||
|
voiceLanguage: LanguageSchema,
|
||||||
voiceLocalTtsSpeakerId: OptionalIntegerSchema,
|
voiceLocalTtsSpeakerId: OptionalIntegerSchema,
|
||||||
voiceLocalTtsSpeed: OptionalFiniteNumberSchema,
|
voiceLocalTtsSpeed: OptionalFiniteNumberSchema,
|
||||||
});
|
});
|
||||||
@@ -90,6 +100,37 @@ function firstDefinedValue<T>(values: Array<T | null | undefined>): T | undefine
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstNonEmptyString(values: Array<string | null | undefined>): string | undefined {
|
||||||
|
for (const value of values) {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalSpeechLanguageResolutionInput(params: {
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
persisted: PersistedConfig;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
const { env, persisted } = params;
|
||||||
|
return {
|
||||||
|
dictationLanguage: firstNonEmptyString([
|
||||||
|
env.PASEO_DICTATION_LANGUAGE,
|
||||||
|
persisted.features?.dictation?.stt?.language,
|
||||||
|
DEFAULT_STT_LANGUAGE,
|
||||||
|
]),
|
||||||
|
voiceLanguage: firstNonEmptyString([
|
||||||
|
env.PASEO_VOICE_LANGUAGE,
|
||||||
|
env.PASEO_DICTATION_LANGUAGE,
|
||||||
|
persisted.features?.voiceMode?.stt?.language,
|
||||||
|
persisted.features?.dictation?.stt?.language,
|
||||||
|
DEFAULT_STT_LANGUAGE,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildLocalSpeechResolutionInput(params: {
|
function buildLocalSpeechResolutionInput(params: {
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
@@ -132,6 +173,7 @@ function buildLocalSpeechResolutionInput(params: {
|
|||||||
),
|
),
|
||||||
DEFAULT_LOCAL_TTS_MODEL,
|
DEFAULT_LOCAL_TTS_MODEL,
|
||||||
]),
|
]),
|
||||||
|
...buildLocalSpeechLanguageResolutionInput({ env, persisted }),
|
||||||
voiceLocalTtsSpeakerId: firstDefinedValue<string | number>([
|
voiceLocalTtsSpeakerId: firstDefinedValue<string | number>([
|
||||||
env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID,
|
env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID,
|
||||||
persisted.features?.voiceMode?.tts?.speakerId,
|
persisted.features?.voiceMode?.tts?.speakerId,
|
||||||
@@ -159,6 +201,10 @@ export function resolveLocalSpeechConfig(params: {
|
|||||||
(parsed.voiceLocalTtsModel === "kokoro-en-v0_19" ? 0 : undefined);
|
(parsed.voiceLocalTtsModel === "kokoro-en-v0_19" ? 0 : undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
sttLanguages: {
|
||||||
|
dictation: parsed.dictationLanguage,
|
||||||
|
voice: parsed.voiceLanguage,
|
||||||
|
},
|
||||||
local: parsed.includeProviderConfig
|
local: parsed.includeProviderConfig
|
||||||
? {
|
? {
|
||||||
modelsDir: parsed.modelsDir,
|
modelsDir: parsed.modelsDir,
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ describe("resolveSpeechConfig", () => {
|
|||||||
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v2-int8");
|
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v2-int8");
|
||||||
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
|
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
|
||||||
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
|
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
|
||||||
|
expect(result.speech.sttLanguages).toEqual({
|
||||||
|
dictation: "en",
|
||||||
|
voice: "en",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolves feature-scoped local model env vars", () => {
|
test("resolves feature-scoped local model env vars", () => {
|
||||||
@@ -71,6 +75,8 @@ describe("resolveSpeechConfig", () => {
|
|||||||
PASEO_VOICE_LOCAL_TTS_MODEL: "kitten",
|
PASEO_VOICE_LOCAL_TTS_MODEL: "kitten",
|
||||||
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
|
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
|
||||||
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
|
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
|
||||||
|
PASEO_DICTATION_LANGUAGE: "es",
|
||||||
|
PASEO_VOICE_LANGUAGE: "pt",
|
||||||
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
|
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
|
||||||
OPENAI_API_KEY: "env-key",
|
OPENAI_API_KEY: "env-key",
|
||||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||||
@@ -119,10 +125,45 @@ describe("resolveSpeechConfig", () => {
|
|||||||
expect(result.speech.local?.models.voiceTts).toBe("kitten-nano-en-v0_1-fp16");
|
expect(result.speech.local?.models.voiceTts).toBe("kitten-nano-en-v0_1-fp16");
|
||||||
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(5);
|
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(5);
|
||||||
expect(result.speech.local?.models.voiceTtsSpeed).toBe(1.35);
|
expect(result.speech.local?.models.voiceTtsSpeed).toBe(1.35);
|
||||||
|
expect(result.speech.sttLanguages).toEqual({
|
||||||
|
dictation: "es",
|
||||||
|
voice: "pt",
|
||||||
|
});
|
||||||
expect(result.openai?.apiKey).toBe("env-key");
|
expect(result.openai?.apiKey).toBe("env-key");
|
||||||
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
|
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resolves STT language from env, settings, and voice-to-dictation fallback", () => {
|
||||||
|
const persisted = PersistedConfigSchema.parse({
|
||||||
|
features: {
|
||||||
|
dictation: {
|
||||||
|
stt: {
|
||||||
|
language: "fr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
voiceMode: {
|
||||||
|
stt: {
|
||||||
|
language: "de",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolveSpeechConfig({
|
||||||
|
paseoHome: "/tmp/paseo-home",
|
||||||
|
env: {
|
||||||
|
PASEO_DICTATION_LANGUAGE: "es",
|
||||||
|
PASEO_VOICE_LANGUAGE: " ",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
persisted,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.speech.sttLanguages).toEqual({
|
||||||
|
dictation: "es",
|
||||||
|
voice: "es",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("ignores deprecated shared local model env vars", () => {
|
test("ignores deprecated shared local model env vars", () => {
|
||||||
const persisted = PersistedConfigSchema.parse({});
|
const persisted = PersistedConfigSchema.parse({});
|
||||||
const env = {
|
const env = {
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export function resolveSpeechConfig(params: {
|
|||||||
openai,
|
openai,
|
||||||
speech: {
|
speech: {
|
||||||
providers,
|
providers,
|
||||||
|
sttLanguages: local.sttLanguages,
|
||||||
...(local.local ? { local: local.local } : {}),
|
...(local.local ? { local: local.local } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ function createStubTurnDetection(id: string): TurnDetectionProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig {
|
function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig {
|
||||||
return { providers };
|
return {
|
||||||
|
providers,
|
||||||
|
sttLanguages: {
|
||||||
|
dictation: "en",
|
||||||
|
voice: "en",
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("createSpeechService readiness", () => {
|
describe("createSpeechService readiness", () => {
|
||||||
|
|||||||
@@ -342,9 +342,11 @@ function resolveEffectiveProviderIds(params: {
|
|||||||
|
|
||||||
export interface SpeechService {
|
export interface SpeechService {
|
||||||
resolveStt: () => SpeechToTextProvider | null;
|
resolveStt: () => SpeechToTextProvider | null;
|
||||||
|
resolveSttLanguage: () => string;
|
||||||
resolveTts: () => TextToSpeechProvider | null;
|
resolveTts: () => TextToSpeechProvider | null;
|
||||||
resolveTurnDetection: () => TurnDetectionProvider | null;
|
resolveTurnDetection: () => TurnDetectionProvider | null;
|
||||||
resolveDictationStt: () => SpeechToTextProvider | null;
|
resolveDictationStt: () => SpeechToTextProvider | null;
|
||||||
|
resolveDictationSttLanguage: () => string;
|
||||||
getReadiness: () => SpeechReadinessSnapshot;
|
getReadiness: () => SpeechReadinessSnapshot;
|
||||||
onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
|
onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
|
||||||
start: () => void;
|
start: () => void;
|
||||||
@@ -709,8 +711,10 @@ export function createSpeechService(params: {
|
|||||||
return {
|
return {
|
||||||
resolveTurnDetection: () => turnDetectionService,
|
resolveTurnDetection: () => turnDetectionService,
|
||||||
resolveStt: () => sttService,
|
resolveStt: () => sttService,
|
||||||
|
resolveSttLanguage: () => speechConfig?.sttLanguages?.voice ?? "en",
|
||||||
resolveTts: () => ttsService,
|
resolveTts: () => ttsService,
|
||||||
resolveDictationStt: () => dictationSttService,
|
resolveDictationStt: () => dictationSttService,
|
||||||
|
resolveDictationSttLanguage: () => speechConfig?.sttLanguages?.dictation ?? "en",
|
||||||
getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
|
getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
|
||||||
onReadinessChange: subscribeSpeechReadiness,
|
onReadinessChange: subscribeSpeechReadiness,
|
||||||
start,
|
start,
|
||||||
|
|||||||
@@ -81,10 +81,14 @@ function createFakeTurnDetectionProvider(session: FakeTurnDetectionSession): Tur
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFakeSttProvider(sessions: FakeSttSession[]): SpeechToTextProvider {
|
function createFakeSttProvider(
|
||||||
|
sessions: FakeSttSession[],
|
||||||
|
captureLanguage?: (language: string | undefined) => void,
|
||||||
|
): SpeechToTextProvider {
|
||||||
return {
|
return {
|
||||||
id: "local",
|
id: "local",
|
||||||
createSession() {
|
createSession(params) {
|
||||||
|
captureLanguage?.(params.language);
|
||||||
const session = new FakeSttSession();
|
const session = new FakeSttSession();
|
||||||
sessions.push(session);
|
sessions.push(session);
|
||||||
return session;
|
return session;
|
||||||
@@ -98,10 +102,13 @@ async function settleSerialQueue(): Promise<void> {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createControllerHarness() {
|
function createControllerHarness(options?: { sttLanguage?: string }) {
|
||||||
const detector = new FakeTurnDetectionSession();
|
const detector = new FakeTurnDetectionSession();
|
||||||
const sttSessions: FakeSttSession[] = [];
|
const sttSessions: FakeSttSession[] = [];
|
||||||
const stt = createFakeSttProvider(sttSessions);
|
let lastSttLanguage: string | undefined;
|
||||||
|
const stt = createFakeSttProvider(sttSessions, (language) => {
|
||||||
|
lastSttLanguage = language;
|
||||||
|
});
|
||||||
const onSpeechStarted = vi.fn(async () => {});
|
const onSpeechStarted = vi.fn(async () => {});
|
||||||
const onSpeechStopped = vi.fn(async () => {});
|
const onSpeechStopped = vi.fn(async () => {});
|
||||||
const onPartialTranscript = vi.fn(
|
const onPartialTranscript = vi.fn(
|
||||||
@@ -123,6 +130,7 @@ function createControllerHarness() {
|
|||||||
logger: pino({ level: "silent" }),
|
logger: pino({ level: "silent" }),
|
||||||
turnDetection: createFakeTurnDetectionProvider(detector),
|
turnDetection: createFakeTurnDetectionProvider(detector),
|
||||||
stt,
|
stt,
|
||||||
|
sttLanguage: options?.sttLanguage,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onSpeechStarted,
|
onSpeechStarted,
|
||||||
onSpeechStopped,
|
onSpeechStopped,
|
||||||
@@ -136,6 +144,7 @@ function createControllerHarness() {
|
|||||||
controller,
|
controller,
|
||||||
detector,
|
detector,
|
||||||
sttSessions,
|
sttSessions,
|
||||||
|
getLastSttLanguage: () => lastSttLanguage,
|
||||||
onSpeechStarted,
|
onSpeechStarted,
|
||||||
onSpeechStopped,
|
onSpeechStopped,
|
||||||
onPartialTranscript,
|
onPartialTranscript,
|
||||||
@@ -145,6 +154,16 @@ function createControllerHarness() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("voice turn controller", () => {
|
describe("voice turn controller", () => {
|
||||||
|
it("passes configured language to streaming STT", async () => {
|
||||||
|
const harness = createControllerHarness({ sttLanguage: "pt" });
|
||||||
|
|
||||||
|
await harness.controller.start();
|
||||||
|
harness.detector.emit("speech_started");
|
||||||
|
await settleSerialQueue();
|
||||||
|
|
||||||
|
expect(harness.getLastSttLanguage()).toBe("pt");
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards audio to the detector and streaming STT without submitting buffered utterances", async () => {
|
it("forwards audio to the detector and streaming STT without submitting buffered utterances", async () => {
|
||||||
const harness = createControllerHarness();
|
const harness = createControllerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export function createVoiceTurnController(params: {
|
|||||||
logger: Logger;
|
logger: Logger;
|
||||||
turnDetection: TurnDetectionProvider;
|
turnDetection: TurnDetectionProvider;
|
||||||
stt: SpeechToTextProvider;
|
stt: SpeechToTextProvider;
|
||||||
|
sttLanguage?: string;
|
||||||
callbacks: VoiceTurnControllerCallbacks;
|
callbacks: VoiceTurnControllerCallbacks;
|
||||||
}): VoiceTurnController {
|
}): VoiceTurnController {
|
||||||
const detector = params.turnDetection.createSession({
|
const detector = params.turnDetection.createSession({
|
||||||
@@ -319,7 +320,7 @@ export function createVoiceTurnController(params: {
|
|||||||
function createSttSession(): StreamingTranscriptionSession {
|
function createSttSession(): StreamingTranscriptionSession {
|
||||||
const session = params.stt.createSession({
|
const session = params.stt.createSession({
|
||||||
logger: params.logger.child({ component: "stt" }),
|
logger: params.logger.child({ component: "stt" }),
|
||||||
language: "en",
|
language: params.sttLanguage ?? "en",
|
||||||
});
|
});
|
||||||
session.on("transcript", handleSttTranscript);
|
session.on("transcript", handleSttTranscript);
|
||||||
session.on("committed", ({ segmentId }) => {
|
session.on("committed", ({ segmentId }) => {
|
||||||
|
|||||||
@@ -215,8 +215,17 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
|||||||
undefined,
|
undefined,
|
||||||
speechReadiness
|
speechReadiness
|
||||||
? {
|
? {
|
||||||
|
resolveStt: () => null,
|
||||||
|
resolveSttLanguage: () => "en",
|
||||||
|
resolveTts: () => null,
|
||||||
|
resolveTurnDetection: () => null,
|
||||||
|
resolveDictationStt: () => null,
|
||||||
|
resolveDictationSttLanguage: () => "en",
|
||||||
getReadiness: () => speechReadiness,
|
getReadiness: () => speechReadiness,
|
||||||
onReadinessChange: vi.fn(() => () => {}),
|
onReadinessChange: vi.fn(() => () => {}),
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
ready: Promise.resolve(),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -878,6 +878,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
daemonConfigStore: this.daemonConfigStore,
|
daemonConfigStore: this.daemonConfigStore,
|
||||||
mcpBaseUrl: this.mcpBaseUrl,
|
mcpBaseUrl: this.mcpBaseUrl,
|
||||||
stt: () => this.speech?.resolveStt() ?? null,
|
stt: () => this.speech?.resolveStt() ?? null,
|
||||||
|
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
|
||||||
tts: () => this.speech?.resolveTts() ?? null,
|
tts: () => this.speech?.resolveTts() ?? null,
|
||||||
terminalManager: this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
providerSnapshotManager: this.providerSnapshotManager,
|
providerSnapshotManager: this.providerSnapshotManager,
|
||||||
@@ -910,6 +911,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
? {
|
? {
|
||||||
finalTimeoutMs: this.dictation?.finalTimeoutMs,
|
finalTimeoutMs: this.dictation?.finalTimeoutMs,
|
||||||
stt: () => this.speech?.resolveDictationStt() ?? null,
|
stt: () => this.speech?.resolveDictationStt() ?? null,
|
||||||
|
sttLanguage: this.speech?.resolveDictationSttLanguage() ?? "en",
|
||||||
getSpeechReadiness: () => this.speech!.getReadiness(),
|
getSpeechReadiness: () => this.speech!.getReadiness(),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ In the mobile app, enter the password in the direct connection setup screen.
|
|||||||
- `PASEO_LOCAL_MODELS_DIR`, control local model directory
|
- `PASEO_LOCAL_MODELS_DIR`, control local model directory
|
||||||
- `PASEO_DICTATION_LOCAL_STT_MODEL`, override local dictation STT model
|
- `PASEO_DICTATION_LOCAL_STT_MODEL`, override local dictation STT model
|
||||||
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, override local voice STT/TTS models
|
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, override local voice STT/TTS models
|
||||||
|
- `PASEO_DICTATION_LANGUAGE`, `PASEO_VOICE_LANGUAGE`, override dictation and voice STT language
|
||||||
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
|
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ This keeps credentials and execution in your environment and avoids introducing
|
|||||||
|
|
||||||
## Local Speech
|
## Local Speech
|
||||||
|
|
||||||
Local speech defaults to model IDs `parakeet-tdt-0.6b-v3-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00).
|
Local speech defaults to model IDs `parakeet-tdt-0.6b-v3-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00). STT language defaults to `en`.
|
||||||
|
|
||||||
Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files.
|
Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files.
|
||||||
|
|
||||||
@@ -32,10 +32,12 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"features": {
|
"features": {
|
||||||
"dictation": { "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8" } },
|
"dictation": {
|
||||||
|
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8", "language": "en" }
|
||||||
|
},
|
||||||
"voiceMode": {
|
"voiceMode": {
|
||||||
"llm": { "provider": "claude", "model": "haiku" },
|
"llm": { "provider": "claude", "model": "haiku" },
|
||||||
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8" },
|
"stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v3-int8", "language": "en" },
|
||||||
"tts": { "provider": "local", "model": "kokoro-en-v0_19", "speakerId": 0 }
|
"tts": { "provider": "local", "model": "kokoro-en-v0_19", "speakerId": 0 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -47,6 +49,8 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for realtime voice. If voice language is omitted, Paseo uses the dictation language before falling back to `en`.
|
||||||
|
|
||||||
## OpenAI Speech Option
|
## OpenAI Speech Option
|
||||||
|
|
||||||
You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing `OPENAI_API_KEY`.
|
You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing `OPENAI_API_KEY`.
|
||||||
@@ -74,6 +78,8 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
|
|||||||
- `PASEO_LOCAL_MODELS_DIR`, local model storage directory
|
- `PASEO_LOCAL_MODELS_DIR`, local model storage directory
|
||||||
- `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID
|
- `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID
|
||||||
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
|
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
|
||||||
|
- `PASEO_DICTATION_LANGUAGE`, dictation STT language
|
||||||
|
- `PASEO_VOICE_LANGUAGE`, realtime voice STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset
|
||||||
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
|
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
|
||||||
|
|
||||||
## Operational Notes
|
## Operational Notes
|
||||||
|
|||||||
Reference in New Issue
Block a user