Configure STT language from settings (#941)

* Configure STT language from settings

* Update websocket speech mock for language config
This commit is contained in:
Mohamed Boudra
2026-05-12 15:03:53 +08:00
committed by GitHub
parent 417abed6a5
commit 1a8fdcd388
20 changed files with 411 additions and 18 deletions
+2 -2
View File
@@ -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);
+6
View File
@@ -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(),
+13 -3
View File
@@ -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,
+1
View File
@@ -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
+9 -3
View File
@@ -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