Files

577 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`)
}
})
}