mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
feat: add mainland-reachable desktop distribution
This commit is contained in:
576
plugins/dsh-desktop-voice/index.js
Normal file
576
plugins/dsh-desktop-voice/index.js
Normal file
@@ -0,0 +1,576 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { execFile } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { Transform, Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
export const name = 'dsh-desktop-voice'
|
||||
export const inject = []
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const SHERPA_VERSION = '1.13.5'
|
||||
const SENSEVOICE_VERSION = '2365baeacb507f821a0c8120fcee3d484dba7a07'
|
||||
const SENSEVOICE_MODEL_BYTES = 239233841
|
||||
const SENSEVOICE_TOKENS_BYTES = 315894
|
||||
const SENSEVOICE_FILES = [
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
bytes: SENSEVOICE_MODEL_BYTES,
|
||||
sha256: 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51',
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
bytes: SENSEVOICE_TOKENS_BYTES,
|
||||
sha256: 'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc',
|
||||
},
|
||||
]
|
||||
const SENSEVOICE_BASE_URL =
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/' +
|
||||
SENSEVOICE_VERSION
|
||||
const SENSEVOICE_DOWNLOAD_BYTES = SENSEVOICE_FILES.reduce((sum, file) => sum + file.bytes, 0)
|
||||
const DOWNLOAD_PROGRESS_LIMIT = 95
|
||||
let installPromise = null
|
||||
let installState = { status: 'missing', percent: 0, stage: '' }
|
||||
let sherpa = null
|
||||
const recognizers = new Map()
|
||||
let recognitionQueue = Promise.resolve()
|
||||
|
||||
const ENGINES = [
|
||||
{ id: 'sensevoice', label: 'SenseVoice(本机离线,推荐)' },
|
||||
{ id: 'openai', label: 'OpenAI 兼容接口' },
|
||||
]
|
||||
const ENGINE_IDS = new Set(ENGINES.map((item) => item.id))
|
||||
const MODES = [
|
||||
{ id: 'toggle', label: '点按开关(再按一次快捷键结束)' },
|
||||
{ id: 'hold', label: '按住说话(松开快捷键结束)' },
|
||||
]
|
||||
const MODE_IDS = new Set(MODES.map((item) => item.id))
|
||||
const LANGUAGES = [
|
||||
{ id: '', label: '自动检测' },
|
||||
{ id: 'zh', label: '中文' },
|
||||
{ id: 'yue', label: '粤语' },
|
||||
{ id: 'en', label: 'English' },
|
||||
{ id: 'ja', label: '日本語' },
|
||||
{ id: 'ko', label: '한국어' },
|
||||
]
|
||||
const LANGUAGE_IDS = new Set(LANGUAGES.map((item) => item.id))
|
||||
|
||||
function configDir() {
|
||||
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'dsh-desktop')
|
||||
return join(homedir(), '.config', 'dsh-desktop')
|
||||
}
|
||||
|
||||
function configPath() {
|
||||
return join(configDir(), 'voice.json')
|
||||
}
|
||||
|
||||
function voiceDataDir() {
|
||||
if (process.env.DSH_DESKTOP_VOICE_HOME) return process.env.DSH_DESKTOP_VOICE_HOME
|
||||
if (process.env.XDG_CACHE_HOME) return join(process.env.XDG_CACHE_HOME, 'dsh-desktop', 'voice')
|
||||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||
return join(process.env.LOCALAPPDATA, 'dsh-desktop', 'Cache', 'voice')
|
||||
}
|
||||
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Caches', 'dsh-desktop', 'voice')
|
||||
return join(homedir(), '.cache', 'dsh-desktop', 'voice')
|
||||
}
|
||||
|
||||
function senseVoiceDir() {
|
||||
return join(voiceDataDir(), 'sensevoice')
|
||||
}
|
||||
|
||||
function runtimeDir() {
|
||||
return join(voiceDataDir(), 'runtime')
|
||||
}
|
||||
|
||||
function markerPath() {
|
||||
return join(senseVoiceDir(), 'installed.json')
|
||||
}
|
||||
|
||||
function senseVoiceInstalled() {
|
||||
try {
|
||||
const marker = JSON.parse(readFileSync(markerPath(), 'utf8'))
|
||||
const runtime = JSON.parse(readFileSync(join(runtimeDir(), 'node_modules', 'sherpa-onnx', 'package.json'), 'utf8'))
|
||||
return (
|
||||
marker.modelVersion === SENSEVOICE_VERSION &&
|
||||
marker.runtimeVersion === SHERPA_VERSION &&
|
||||
runtime.version === SHERPA_VERSION &&
|
||||
SENSEVOICE_FILES.every((file) => statSync(join(senseVoiceDir(), file.name)).size === file.bytes)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function senseVoiceProgress() {
|
||||
if (senseVoiceInstalled()) return { status: 'installed', percent: 100, stage: 'SenseVoice 已安装' }
|
||||
if (installPromise) return { ...installState }
|
||||
if (installState.status === 'failed') return { ...installState }
|
||||
return { status: 'missing', percent: 0, stage: '' }
|
||||
}
|
||||
|
||||
function senseVoiceStatus() {
|
||||
return senseVoiceProgress().status
|
||||
}
|
||||
|
||||
function defaults() {
|
||||
return {
|
||||
enabled: true,
|
||||
engine: 'sensevoice',
|
||||
dictationMode: 'toggle',
|
||||
language: 'zh',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'whisper-1',
|
||||
microphoneDeviceId: '',
|
||||
microphoneDeviceLabel: '',
|
||||
}
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(configPath(), 'utf8'))
|
||||
return data && typeof data === 'object' ? data : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function formOf(cfg) {
|
||||
const base = defaults()
|
||||
const engine = ENGINE_IDS.has(cfg.engine) ? cfg.engine : base.engine
|
||||
const dictationMode = MODE_IDS.has(cfg.dictationMode) ? cfg.dictationMode : base.dictationMode
|
||||
const language = LANGUAGE_IDS.has(String(cfg.language ?? base.language))
|
||||
? String(cfg.language ?? base.language)
|
||||
: base.language
|
||||
return {
|
||||
enabled: cfg.enabled !== false,
|
||||
engine,
|
||||
dictationMode,
|
||||
language,
|
||||
apiKey: String(cfg.apiKey || ''),
|
||||
baseUrl: String(cfg.baseUrl || base.baseUrl),
|
||||
model: String(cfg.model || base.model),
|
||||
microphoneDeviceId: String(cfg.microphoneDeviceId || ''),
|
||||
microphoneDeviceLabel: String(cfg.microphoneDeviceLabel || ''),
|
||||
modelInstalled: senseVoiceInstalled(),
|
||||
modelStatus: senseVoiceStatus(),
|
||||
modelDownloadSizeMb: 245,
|
||||
modelProgress: senseVoiceProgress(),
|
||||
options: {
|
||||
engines: ENGINES,
|
||||
modes: MODES,
|
||||
languages: LANGUAGES,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function saveForm(values) {
|
||||
const next = formOf({ ...loadConfig(), ...values })
|
||||
const path = configPath()
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
enabled: next.enabled,
|
||||
engine: next.engine,
|
||||
dictationMode: next.dictationMode,
|
||||
language: next.language,
|
||||
apiKey: next.apiKey,
|
||||
baseUrl: next.baseUrl,
|
||||
model: next.model,
|
||||
microphoneDeviceId: next.microphoneDeviceId,
|
||||
microphoneDeviceLabel: next.microphoneDeviceLabel,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
try {
|
||||
chmodSync(path, 0o600)
|
||||
} catch {
|
||||
// best-effort; some filesystems ignore mode
|
||||
}
|
||||
return formOf(loadConfig())
|
||||
}
|
||||
|
||||
function whisperLanguage(code) {
|
||||
const raw = String(code || '').trim().toLowerCase()
|
||||
if (!raw || raw === 'auto') return ''
|
||||
return raw.split(/[-_]/, 1)[0]
|
||||
}
|
||||
|
||||
function transcriptionUrl(baseUrl) {
|
||||
const trimmed = String(baseUrl || '').trim().replace(/\/+$/, '')
|
||||
if (!trimmed) return 'https://api.openai.com/v1/audio/transcriptions'
|
||||
if (/\/audio\/transcriptions$/i.test(trimmed)) return trimmed
|
||||
return `${trimmed}/audio/transcriptions`
|
||||
}
|
||||
|
||||
function filenameFor(mime) {
|
||||
const type = String(mime || '').toLowerCase()
|
||||
if (type.includes('wav')) return 'audio.wav'
|
||||
if (type.includes('mpeg') || type.includes('mp3')) return 'audio.mp3'
|
||||
if (type.includes('mp4') || type.includes('m4a')) return 'audio.m4a'
|
||||
if (type.includes('ogg')) return 'audio.ogg'
|
||||
return 'audio.webm'
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
const raw = (await readBody(req)).toString('utf8').trim()
|
||||
if (!raw) return {}
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function sha256File(path) {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function ensureModelFile(file, reportBytes) {
|
||||
const dest = join(senseVoiceDir(), file.name)
|
||||
try {
|
||||
if (statSync(dest).size === file.bytes && (await sha256File(dest)) === file.sha256) {
|
||||
reportBytes(file.bytes)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
mkdirSync(dirname(dest), { recursive: true })
|
||||
const partial = `${dest}.part`
|
||||
rmSync(partial, { force: true })
|
||||
const response = await fetch(`${SENSEVOICE_BASE_URL}/${file.name}?download=true`, {
|
||||
signal: AbortSignal.timeout(900000),
|
||||
})
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`下载 ${file.name} 失败:HTTP ${response.status}`)
|
||||
}
|
||||
const hash = createHash('sha256')
|
||||
let bytes = 0
|
||||
const verify = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
bytes += chunk.length
|
||||
reportBytes(chunk.length)
|
||||
hash.update(chunk)
|
||||
callback(null, chunk)
|
||||
},
|
||||
})
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(response.body), verify, createWriteStream(partial, { mode: 0o600 }))
|
||||
const digest = hash.digest('hex')
|
||||
if (bytes !== file.bytes || digest !== file.sha256) {
|
||||
throw new Error(`下载的 ${file.name} 校验失败`)
|
||||
}
|
||||
renameSync(partial, dest)
|
||||
} catch (error) {
|
||||
rmSync(partial, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function npmInvocation() {
|
||||
if (process.env.npm_execpath && existsSync(process.env.npm_execpath)) {
|
||||
return { file: process.execPath, prefix: [process.env.npm_execpath] }
|
||||
}
|
||||
const configured = process.env.DSH_DESKTOP_NPM
|
||||
if (configured) return { file: configured, prefix: [] }
|
||||
for (const candidate of ['/app/bin/npm', '/app/node24/bin/npm']) {
|
||||
if (existsSync(candidate)) return { file: candidate, prefix: [] }
|
||||
}
|
||||
if (process.platform === 'win32') return { file: 'cmd.exe', prefix: ['/d', '/s', '/c', 'npm.cmd'] }
|
||||
return { file: 'npm', prefix: [] }
|
||||
}
|
||||
|
||||
async function installSherpaRuntime() {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(runtimeDir(), 'node_modules', 'sherpa-onnx', 'package.json'), 'utf8'))
|
||||
if (pkg.version === SHERPA_VERSION) return
|
||||
} catch {}
|
||||
mkdirSync(runtimeDir(), { recursive: true })
|
||||
const npm = npmInvocation()
|
||||
try {
|
||||
await execFileAsync(
|
||||
npm.file,
|
||||
[
|
||||
...npm.prefix,
|
||||
'install',
|
||||
'--prefix',
|
||||
runtimeDir(),
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--omit=dev',
|
||||
'--save-exact',
|
||||
`sherpa-onnx@${SHERPA_VERSION}`,
|
||||
],
|
||||
{ timeout: 300000, maxBuffer: 1024 * 1024 },
|
||||
)
|
||||
} catch (error) {
|
||||
const detail = String(error?.stderr || error?.message || error).trim()
|
||||
throw new Error(`安装离线识别运行时失败:${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function installSenseVoice() {
|
||||
if (senseVoiceInstalled()) return { ...formOf(loadConfig()), installed: true }
|
||||
if (!installPromise) {
|
||||
installState = { status: 'installing', percent: 0, stage: '正在准备离线识别运行时…' }
|
||||
installPromise = (async () => {
|
||||
mkdirSync(senseVoiceDir(), { recursive: true })
|
||||
rmSync(markerPath(), { force: true })
|
||||
let downloadedBytes = 0
|
||||
const reportBytes = (bytes) => {
|
||||
downloadedBytes += bytes
|
||||
installState = {
|
||||
status: 'installing',
|
||||
percent: Math.min(
|
||||
DOWNLOAD_PROGRESS_LIMIT,
|
||||
Math.floor((downloadedBytes / SENSEVOICE_DOWNLOAD_BYTES) * DOWNLOAD_PROGRESS_LIMIT),
|
||||
),
|
||||
stage: '正在下载 SenseVoice 模型…',
|
||||
}
|
||||
}
|
||||
const runtime = installSherpaRuntime()
|
||||
await Promise.all(SENSEVOICE_FILES.map((file) => ensureModelFile(file, reportBytes)))
|
||||
installState = {
|
||||
status: 'installing',
|
||||
percent: DOWNLOAD_PROGRESS_LIMIT,
|
||||
stage: '正在完成离线识别运行时…',
|
||||
}
|
||||
await runtime
|
||||
const partial = `${markerPath()}.part`
|
||||
writeFileSync(
|
||||
partial,
|
||||
`${JSON.stringify({ modelVersion: SENSEVOICE_VERSION, runtimeVersion: SHERPA_VERSION }, null, 2)}\n`,
|
||||
{ encoding: 'utf8', mode: 0o600 },
|
||||
)
|
||||
renameSync(partial, markerPath())
|
||||
installState = { status: 'installed', percent: 100, stage: 'SenseVoice 已安装' }
|
||||
})()
|
||||
.catch((error) => {
|
||||
installState = {
|
||||
status: 'failed',
|
||||
percent: installState.percent,
|
||||
stage: String(error?.message || error),
|
||||
}
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
installPromise = null
|
||||
})
|
||||
}
|
||||
await installPromise
|
||||
return { ...formOf(loadConfig()), installed: true }
|
||||
}
|
||||
|
||||
function sherpaModule() {
|
||||
if (!sherpa) {
|
||||
const require = createRequire(join(runtimeDir(), 'package.json'))
|
||||
sherpa = require('sherpa-onnx')
|
||||
}
|
||||
return sherpa
|
||||
}
|
||||
|
||||
function senseVoiceRecognizer(language) {
|
||||
const code = whisperLanguage(language)
|
||||
if (recognizers.has(code)) return recognizers.get(code)
|
||||
const runtime = sherpaModule()
|
||||
const recognizer = runtime.createOfflineRecognizer({
|
||||
modelConfig: {
|
||||
senseVoice: {
|
||||
model: join(senseVoiceDir(), 'model.int8.onnx'),
|
||||
language: code,
|
||||
useInverseTextNormalization: 1,
|
||||
},
|
||||
tokens: join(senseVoiceDir(), 'tokens.txt'),
|
||||
},
|
||||
})
|
||||
recognizers.set(code, recognizer)
|
||||
return recognizer
|
||||
}
|
||||
|
||||
function enqueueRecognition(task) {
|
||||
const next = recognitionQueue.then(task, task)
|
||||
recognitionQueue = next.catch(() => {})
|
||||
return next
|
||||
}
|
||||
|
||||
async function transcribeSenseVoice(audio, mime, language) {
|
||||
if (!senseVoiceInstalled()) {
|
||||
const error = new Error('SenseVoice 离线模型尚未安装')
|
||||
error.status = 409
|
||||
throw error
|
||||
}
|
||||
if (!String(mime).toLowerCase().includes('wav')) {
|
||||
const error = new Error('SenseVoice 离线识别需要 WAV 音频')
|
||||
error.status = 415
|
||||
throw error
|
||||
}
|
||||
const path = join(tmpdir(), `dsh-desktop-voice-${randomUUID()}.wav`)
|
||||
writeFileSync(path, audio, { mode: 0o600 })
|
||||
try {
|
||||
return await enqueueRecognition(() => {
|
||||
const runtime = sherpaModule()
|
||||
const recognizer = senseVoiceRecognizer(language)
|
||||
const wave = runtime.readWave(path)
|
||||
const stream = recognizer.createStream()
|
||||
try {
|
||||
stream.acceptWaveform(wave.sampleRate, wave.samples)
|
||||
recognizer.decode(stream)
|
||||
return String(recognizer.getResult(stream).text || '').trim()
|
||||
} finally {
|
||||
stream.free()
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
rmSync(path, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function transcribeOpenAI(cfg, audio, mime, language) {
|
||||
if (!cfg.apiKey) {
|
||||
const error = new Error('未配置 OpenAI API 密钥')
|
||||
error.status = 400
|
||||
throw error
|
||||
}
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([audio], { type: mime }), filenameFor(mime))
|
||||
form.append('model', cfg.model || 'whisper-1')
|
||||
form.append('response_format', 'json')
|
||||
if (language) form.append('language', language)
|
||||
const response = await fetch(transcriptionUrl(cfg.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${cfg.apiKey}` },
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const text = await response.text()
|
||||
let body = {}
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {}
|
||||
} catch {
|
||||
body = { error: text.slice(0, 400) }
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = body.error?.message || body.error || body.message || text.slice(0, 400)
|
||||
const error = new Error(String(detail || `OpenAI 接口返回 ${response.status}`))
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
return String(body.text || body.transcript || '').trim()
|
||||
}
|
||||
|
||||
async function transcribe(req) {
|
||||
const cfg = formOf(loadConfig())
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
const mime = String(req.headers['content-type'] || url.searchParams.get('mime') || 'application/octet-stream')
|
||||
const language = whisperLanguage(url.searchParams.get('language') || cfg.language)
|
||||
const audio = await readBody(req)
|
||||
if (!audio.length) {
|
||||
const error = new Error('没有收到音频')
|
||||
error.status = 400
|
||||
throw error
|
||||
}
|
||||
const transcript =
|
||||
cfg.engine === 'openai'
|
||||
? await transcribeOpenAI(cfg, audio, mime, language)
|
||||
: await transcribeSenseVoice(audio, mime, language)
|
||||
if (!transcript) {
|
||||
const error = new Error('没有识别到语音')
|
||||
error.status = 422
|
||||
throw error
|
||||
}
|
||||
return { text: transcript }
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
if (typeof ctx.inject !== 'function') return
|
||||
ctx.inject(['webServer'], (scope) => {
|
||||
try {
|
||||
scope.webServer.register({
|
||||
name: 'dsh-desktop-voice',
|
||||
kind: 'exact',
|
||||
path: '/dsh-desktop/voice',
|
||||
handler: async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
json(res, 200, formOf(loadConfig()))
|
||||
return
|
||||
}
|
||||
if (req.method === 'PUT' || req.method === 'POST') {
|
||||
json(res, 200, { ...saveForm(await readJsonBody(req)), saved: true })
|
||||
return
|
||||
}
|
||||
res.writeHead(405).end()
|
||||
} catch (error) {
|
||||
json(res, error.status || 500, { error: String(error?.message || error) })
|
||||
}
|
||||
},
|
||||
})
|
||||
scope.webServer.register({
|
||||
name: 'dsh-desktop-voice-model',
|
||||
kind: 'exact',
|
||||
path: '/dsh-desktop/voice/model',
|
||||
handler: async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
json(res, 200, senseVoiceProgress())
|
||||
return
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
json(res, 200, await installSenseVoice())
|
||||
return
|
||||
}
|
||||
res.writeHead(405).end()
|
||||
} catch (error) {
|
||||
json(res, error.status || 500, { error: String(error?.message || error) })
|
||||
}
|
||||
},
|
||||
})
|
||||
scope.webServer.register({
|
||||
name: 'dsh-desktop-voice-transcribe',
|
||||
kind: 'exact',
|
||||
path: '/dsh-desktop/voice/transcribe',
|
||||
handler: async (req, res) => {
|
||||
try {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(405).end()
|
||||
return
|
||||
}
|
||||
json(res, 200, await transcribe(req))
|
||||
} catch (error) {
|
||||
json(res, error.status || 500, { error: String(error?.message || error) })
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[dsh-desktop-voice] routes skipped: ${error}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user