Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
/**
|
|
* Azure Speech provider descriptor. It reads config/env defaults, parses speech
|
|
* directives, lists voices, and calls the Azure TTS runtime helper.
|
|
*/
|
|
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
|
import type {
|
|
SpeechDirectiveTokenParseContext,
|
|
SpeechProviderConfig,
|
|
SpeechProviderOverrides,
|
|
SpeechProviderPlugin,
|
|
} from "openclaw/plugin-sdk/speech-core";
|
|
import { asFiniteNumber, asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core";
|
|
import {
|
|
azureSpeechTTS,
|
|
DEFAULT_AZURE_SPEECH_AUDIO_FORMAT,
|
|
DEFAULT_AZURE_SPEECH_LANG,
|
|
DEFAULT_AZURE_SPEECH_TELEPHONY_FORMAT,
|
|
DEFAULT_AZURE_SPEECH_VOICE,
|
|
DEFAULT_AZURE_SPEECH_VOICE_NOTE_FORMAT,
|
|
inferAzureSpeechFileExtension,
|
|
isAzureSpeechVoiceCompatible,
|
|
listAzureSpeechVoices,
|
|
normalizeAzureSpeechBaseUrl,
|
|
} from "./tts.js";
|
|
|
|
const DEFAULT_GENERATED_AUDIO_MAX_BYTES = 16 * 1024 * 1024;
|
|
|
|
type AzureSpeechProviderConfig = {
|
|
apiKey?: string;
|
|
region?: string;
|
|
endpoint?: string;
|
|
baseUrl?: string;
|
|
voice: string;
|
|
lang: string;
|
|
outputFormat: string;
|
|
voiceNoteOutputFormat: string;
|
|
timeoutMs?: number;
|
|
};
|
|
|
|
type AzureSpeechProviderOverrides = {
|
|
voice?: string;
|
|
lang?: string;
|
|
outputFormat?: string;
|
|
};
|
|
|
|
function readAzureSpeechEnvApiKey(): string | undefined {
|
|
return (
|
|
trimToUndefined(process.env.AZURE_SPEECH_KEY) ??
|
|
trimToUndefined(process.env.AZURE_SPEECH_API_KEY) ??
|
|
trimToUndefined(process.env.SPEECH_KEY)
|
|
);
|
|
}
|
|
|
|
function readAzureSpeechEnvRegion(): string | undefined {
|
|
return (
|
|
trimToUndefined(process.env.AZURE_SPEECH_REGION) ?? trimToUndefined(process.env.SPEECH_REGION)
|
|
);
|
|
}
|
|
|
|
function readAzureSpeechEnvEndpoint(): string | undefined {
|
|
return trimToUndefined(process.env.AZURE_SPEECH_ENDPOINT);
|
|
}
|
|
|
|
function resolveAzureSpeechConfigRecord(
|
|
rawConfig: Record<string, unknown>,
|
|
): Record<string, unknown> | undefined {
|
|
const providers = asObject(rawConfig.providers);
|
|
return (
|
|
asObject(providers?.["azure-speech"]) ??
|
|
asObject(providers?.azure) ??
|
|
asObject(rawConfig["azure-speech"]) ??
|
|
asObject(rawConfig.azure)
|
|
);
|
|
}
|
|
|
|
function normalizeAzureSpeechProviderConfig(
|
|
rawConfig: Record<string, unknown>,
|
|
): AzureSpeechProviderConfig {
|
|
const raw = resolveAzureSpeechConfigRecord(rawConfig);
|
|
const region = trimToUndefined(raw?.region) ?? readAzureSpeechEnvRegion();
|
|
const endpoint = trimToUndefined(raw?.endpoint) ?? readAzureSpeechEnvEndpoint();
|
|
const baseUrl = normalizeAzureSpeechBaseUrl({
|
|
baseUrl: trimToUndefined(raw?.baseUrl),
|
|
endpoint,
|
|
region,
|
|
});
|
|
return {
|
|
apiKey: normalizeResolvedSecretInputString({
|
|
value: raw?.apiKey,
|
|
path: "messages.tts.providers.azure-speech.apiKey",
|
|
}),
|
|
region,
|
|
endpoint,
|
|
baseUrl,
|
|
voice: trimToUndefined(raw?.voice ?? raw?.voiceId) ?? DEFAULT_AZURE_SPEECH_VOICE,
|
|
lang: trimToUndefined(raw?.lang ?? raw?.languageCode) ?? DEFAULT_AZURE_SPEECH_LANG,
|
|
outputFormat: trimToUndefined(raw?.outputFormat) ?? DEFAULT_AZURE_SPEECH_AUDIO_FORMAT,
|
|
voiceNoteOutputFormat:
|
|
trimToUndefined(raw?.voiceNoteOutputFormat) ?? DEFAULT_AZURE_SPEECH_VOICE_NOTE_FORMAT,
|
|
timeoutMs: asFiniteNumber(raw?.timeoutMs),
|
|
};
|
|
}
|
|
|
|
function readAzureSpeechProviderConfig(config: SpeechProviderConfig): AzureSpeechProviderConfig {
|
|
const defaults = normalizeAzureSpeechProviderConfig({});
|
|
const region = trimToUndefined(config.region) ?? defaults.region;
|
|
const endpoint = trimToUndefined(config.endpoint) ?? defaults.endpoint;
|
|
const baseUrl = normalizeAzureSpeechBaseUrl({
|
|
baseUrl: trimToUndefined(config.baseUrl) ?? defaults.baseUrl,
|
|
endpoint,
|
|
region,
|
|
});
|
|
return {
|
|
apiKey: trimToUndefined(config.apiKey) ?? defaults.apiKey,
|
|
region,
|
|
endpoint,
|
|
baseUrl,
|
|
voice: trimToUndefined(config.voice ?? config.voiceId) ?? defaults.voice,
|
|
lang: trimToUndefined(config.lang ?? config.languageCode) ?? defaults.lang,
|
|
outputFormat: trimToUndefined(config.outputFormat) ?? defaults.outputFormat,
|
|
voiceNoteOutputFormat:
|
|
trimToUndefined(config.voiceNoteOutputFormat) ?? defaults.voiceNoteOutputFormat,
|
|
timeoutMs: asFiniteNumber(config.timeoutMs) ?? defaults.timeoutMs,
|
|
};
|
|
}
|
|
|
|
function readAzureSpeechOverrides(
|
|
overrides: SpeechProviderOverrides | undefined,
|
|
): AzureSpeechProviderOverrides {
|
|
if (!overrides) {
|
|
return {};
|
|
}
|
|
return {
|
|
voice: trimToUndefined(overrides.voice ?? overrides.voiceId),
|
|
lang: trimToUndefined(overrides.lang ?? overrides.languageCode),
|
|
outputFormat: trimToUndefined(overrides.outputFormat),
|
|
};
|
|
}
|
|
|
|
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
|
|
handled: boolean;
|
|
overrides?: SpeechProviderOverrides;
|
|
} {
|
|
switch (ctx.key) {
|
|
case "voice":
|
|
case "voiceid":
|
|
case "voice_id":
|
|
case "azure_voice":
|
|
case "azurevoice":
|
|
case "azure_speech_voice":
|
|
if (!ctx.policy.allowVoice) {
|
|
return { handled: true };
|
|
}
|
|
return { handled: true, overrides: { ...ctx.currentOverrides, voice: ctx.value } };
|
|
case "lang":
|
|
case "language":
|
|
case "language_code":
|
|
case "languagecode":
|
|
case "azure_lang":
|
|
case "azure_language":
|
|
if (!ctx.policy.allowVoiceSettings) {
|
|
return { handled: true };
|
|
}
|
|
return { handled: true, overrides: { ...ctx.currentOverrides, lang: ctx.value } };
|
|
case "output_format":
|
|
case "outputformat":
|
|
case "azure_format":
|
|
case "azure_output_format":
|
|
if (!ctx.policy.allowVoiceSettings) {
|
|
return { handled: true };
|
|
}
|
|
return { handled: true, overrides: { ...ctx.currentOverrides, outputFormat: ctx.value } };
|
|
default:
|
|
return { handled: false };
|
|
}
|
|
}
|
|
|
|
function resolveApiKey(config: AzureSpeechProviderConfig): string | undefined {
|
|
return config.apiKey ?? readAzureSpeechEnvApiKey();
|
|
}
|
|
|
|
function resolveTimeoutMs(config: AzureSpeechProviderConfig, timeoutMs: number): number {
|
|
return config.timeoutMs ?? timeoutMs;
|
|
}
|
|
|
|
function resolveGeneratedAudioMaxBytes(req: {
|
|
cfg: { agents?: { defaults?: { mediaMaxMb?: number } } };
|
|
}): number {
|
|
const configured = req.cfg.agents?.defaults?.mediaMaxMb;
|
|
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
|
|
return Math.floor(configured * 1024 * 1024);
|
|
}
|
|
return DEFAULT_GENERATED_AUDIO_MAX_BYTES;
|
|
}
|
|
|
|
/** Build the Azure Speech provider descriptor for the speech-core runtime. */
|
|
export function buildAzureSpeechProvider(): SpeechProviderPlugin {
|
|
return {
|
|
id: "azure-speech",
|
|
label: "Azure Speech",
|
|
aliases: ["azure"],
|
|
autoSelectOrder: 30,
|
|
resolveConfig: ({ rawConfig }) => normalizeAzureSpeechProviderConfig(rawConfig),
|
|
parseDirectiveToken,
|
|
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
|
|
const base = normalizeAzureSpeechProviderConfig(baseTtsConfig);
|
|
const apiKey =
|
|
talkProviderConfig.apiKey === undefined
|
|
? undefined
|
|
: normalizeResolvedSecretInputString({
|
|
value: talkProviderConfig.apiKey,
|
|
path: "talk.providers.azure-speech.apiKey",
|
|
});
|
|
const region = trimToUndefined(talkProviderConfig.region);
|
|
const endpoint = trimToUndefined(talkProviderConfig.endpoint ?? talkProviderConfig.baseUrl);
|
|
const baseUrl = normalizeAzureSpeechBaseUrl({
|
|
baseUrl: trimToUndefined(talkProviderConfig.baseUrl),
|
|
endpoint,
|
|
region: region ?? base.region,
|
|
});
|
|
return {
|
|
...base,
|
|
...(apiKey === undefined ? {} : { apiKey }),
|
|
...(region === undefined ? {} : { region }),
|
|
...(endpoint === undefined ? {} : { endpoint }),
|
|
...(baseUrl === undefined ? {} : { baseUrl }),
|
|
...(trimToUndefined(talkProviderConfig.voiceId) == null
|
|
? {}
|
|
: { voice: trimToUndefined(talkProviderConfig.voiceId) }),
|
|
...(trimToUndefined(talkProviderConfig.languageCode) == null
|
|
? {}
|
|
: { lang: trimToUndefined(talkProviderConfig.languageCode) }),
|
|
...(trimToUndefined(talkProviderConfig.outputFormat) == null
|
|
? {}
|
|
: { outputFormat: trimToUndefined(talkProviderConfig.outputFormat) }),
|
|
};
|
|
},
|
|
resolveTalkOverrides: ({ params }) => ({
|
|
...(trimToUndefined(params.voiceId) == null
|
|
? {}
|
|
: { voice: trimToUndefined(params.voiceId) }),
|
|
...(trimToUndefined(params.languageCode) == null
|
|
? {}
|
|
: { lang: trimToUndefined(params.languageCode) }),
|
|
...(trimToUndefined(params.outputFormat) == null
|
|
? {}
|
|
: { outputFormat: trimToUndefined(params.outputFormat) }),
|
|
}),
|
|
listVoices: async (req) => {
|
|
const config = req.providerConfig
|
|
? readAzureSpeechProviderConfig(req.providerConfig)
|
|
: undefined;
|
|
const apiKey = req.apiKey ?? (config ? resolveApiKey(config) : readAzureSpeechEnvApiKey());
|
|
if (!apiKey) {
|
|
throw new Error("Azure Speech API key missing");
|
|
}
|
|
return listAzureSpeechVoices({
|
|
apiKey,
|
|
baseUrl: req.baseUrl ?? config?.baseUrl,
|
|
endpoint: config?.endpoint,
|
|
region: config?.region ?? readAzureSpeechEnvRegion(),
|
|
timeoutMs: config?.timeoutMs,
|
|
});
|
|
},
|
|
isConfigured: ({ providerConfig }) => {
|
|
const config = readAzureSpeechProviderConfig(providerConfig);
|
|
return Boolean(resolveApiKey(config) && (config.baseUrl || config.region || config.endpoint));
|
|
},
|
|
synthesize: async (req) => {
|
|
const config = readAzureSpeechProviderConfig(req.providerConfig);
|
|
const overrides = readAzureSpeechOverrides(req.providerOverrides);
|
|
const apiKey = resolveApiKey(config);
|
|
if (!apiKey) {
|
|
throw new Error("Azure Speech API key missing");
|
|
}
|
|
const outputFormat =
|
|
overrides.outputFormat ??
|
|
(req.target === "voice-note" ? config.voiceNoteOutputFormat : config.outputFormat);
|
|
const audioBuffer = await azureSpeechTTS({
|
|
text: req.text,
|
|
apiKey,
|
|
baseUrl: config.baseUrl,
|
|
endpoint: config.endpoint,
|
|
region: config.region,
|
|
voice: overrides.voice ?? config.voice,
|
|
lang: overrides.lang ?? config.lang,
|
|
outputFormat,
|
|
timeoutMs: resolveTimeoutMs(config, req.timeoutMs),
|
|
maxBytes: resolveGeneratedAudioMaxBytes(req),
|
|
});
|
|
return {
|
|
audioBuffer,
|
|
outputFormat,
|
|
fileExtension: inferAzureSpeechFileExtension(outputFormat),
|
|
voiceCompatible: isAzureSpeechVoiceCompatible(outputFormat),
|
|
};
|
|
},
|
|
synthesizeTelephony: async (req) => {
|
|
const config = readAzureSpeechProviderConfig(req.providerConfig);
|
|
const overrides = readAzureSpeechOverrides(req.providerOverrides);
|
|
const apiKey = resolveApiKey(config);
|
|
if (!apiKey) {
|
|
throw new Error("Azure Speech API key missing");
|
|
}
|
|
const sampleRate = 8_000;
|
|
const audioBuffer = await azureSpeechTTS({
|
|
text: req.text,
|
|
apiKey,
|
|
baseUrl: config.baseUrl,
|
|
endpoint: config.endpoint,
|
|
region: config.region,
|
|
voice: overrides.voice ?? config.voice,
|
|
lang: overrides.lang ?? config.lang,
|
|
outputFormat: DEFAULT_AZURE_SPEECH_TELEPHONY_FORMAT,
|
|
timeoutMs: resolveTimeoutMs(config, req.timeoutMs),
|
|
maxBytes: resolveGeneratedAudioMaxBytes(req),
|
|
});
|
|
return {
|
|
audioBuffer,
|
|
outputFormat: DEFAULT_AZURE_SPEECH_TELEPHONY_FORMAT,
|
|
sampleRate,
|
|
};
|
|
},
|
|
};
|
|
}
|