chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import {
|
||||
speechTranscriptionInputSchema,
|
||||
speechTranscriptionResultSchema,
|
||||
type SpeechTranscriptionInput,
|
||||
type SpeechTranscriptionResult
|
||||
} from '../../shared/speech-model-contracts'
|
||||
import type {
|
||||
SelectedSpeechRuntimeModel,
|
||||
SpeechModelManager
|
||||
} from './speech-model-manager'
|
||||
|
||||
const TRANSCRIPTION_TIMEOUT_MS = 120_000
|
||||
|
||||
type SherpaRecognizerConfig = {
|
||||
featConfig: {
|
||||
sampleRate: number
|
||||
featureDim: number
|
||||
}
|
||||
modelConfig: {
|
||||
tokens: string
|
||||
numThreads: number
|
||||
debug: number
|
||||
provider: 'cpu'
|
||||
senseVoice?: {
|
||||
model: string
|
||||
language: string
|
||||
useInverseTextNormalization: number
|
||||
}
|
||||
whisper?: {
|
||||
encoder: string
|
||||
decoder: string
|
||||
language: string
|
||||
task: 'transcribe'
|
||||
tailPaddings: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SpeechTranscriptionRunner = (
|
||||
config: SherpaRecognizerConfig,
|
||||
samples: Float32Array,
|
||||
sampleRate: number,
|
||||
signal: AbortSignal
|
||||
) => Promise<string>
|
||||
|
||||
type SpeechModelResolver = Pick<
|
||||
SpeechModelManager,
|
||||
'getSelectedRuntimeModel'
|
||||
>
|
||||
|
||||
const workerSource = String.raw`
|
||||
const { parentPort, workerData } = require('node:worker_threads')
|
||||
|
||||
let recognizer
|
||||
let stream
|
||||
try {
|
||||
const sherpa = require(workerData.sherpaModulePath)
|
||||
recognizer = sherpa.createOfflineRecognizer(workerData.config)
|
||||
stream = recognizer.createStream()
|
||||
stream.acceptWaveform(
|
||||
workerData.sampleRate,
|
||||
new Float32Array(workerData.samples)
|
||||
)
|
||||
recognizer.decode(stream)
|
||||
const result = recognizer.getResult(stream)
|
||||
parentPort.postMessage({
|
||||
ok: true,
|
||||
text: typeof result?.text === 'string' ? result.text : ''
|
||||
})
|
||||
} catch {
|
||||
parentPort.postMessage({ ok: false })
|
||||
} finally {
|
||||
stream?.free()
|
||||
recognizer?.free()
|
||||
}
|
||||
`
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error('语音识别已取消')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
function requiredFile(
|
||||
model: SelectedSpeechRuntimeModel,
|
||||
role: SelectedSpeechRuntimeModel['files'][number]['role']
|
||||
): string {
|
||||
const file = model.files.find((candidate) => candidate.role === role)
|
||||
if (!file) {
|
||||
throw new Error('所选语音模型文件不完整,请重新安装模型')
|
||||
}
|
||||
return join(model.directory, file.name)
|
||||
}
|
||||
|
||||
export function createSherpaRecognizerConfig(
|
||||
model: SelectedSpeechRuntimeModel
|
||||
): SherpaRecognizerConfig {
|
||||
const tokens = requiredFile(model, 'tokens')
|
||||
const base = {
|
||||
featConfig: {
|
||||
sampleRate: 16_000,
|
||||
featureDim: 80
|
||||
},
|
||||
modelConfig: {
|
||||
tokens,
|
||||
numThreads: 2,
|
||||
debug: 0,
|
||||
provider: 'cpu' as const
|
||||
}
|
||||
}
|
||||
if (model.family === 'sensevoice') {
|
||||
return {
|
||||
...base,
|
||||
modelConfig: {
|
||||
...base.modelConfig,
|
||||
senseVoice: {
|
||||
model: requiredFile(model, 'model'),
|
||||
language: 'auto',
|
||||
useInverseTextNormalization: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
modelConfig: {
|
||||
...base.modelConfig,
|
||||
whisper: {
|
||||
encoder: requiredFile(model, 'encoder'),
|
||||
decoder: requiredFile(model, 'decoder'),
|
||||
language: '',
|
||||
task: 'transcribe',
|
||||
tailPaddings: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const runSherpaTranscription: SpeechTranscriptionRunner = (
|
||||
config,
|
||||
samples,
|
||||
sampleRate,
|
||||
signal
|
||||
) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(createAbortError())
|
||||
return
|
||||
}
|
||||
const audioBuffer = samples.buffer as ArrayBuffer
|
||||
const worker = new Worker(workerSource, {
|
||||
eval: true,
|
||||
workerData: {
|
||||
sherpaModulePath: require.resolve('sherpa-onnx'),
|
||||
config,
|
||||
sampleRate,
|
||||
samples: audioBuffer
|
||||
},
|
||||
transferList: [audioBuffer]
|
||||
})
|
||||
let settled = false
|
||||
const finish = (
|
||||
action: () => void,
|
||||
terminate = true
|
||||
): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener('abort', abort)
|
||||
worker.removeAllListeners()
|
||||
if (terminate) {
|
||||
void worker.terminate()
|
||||
}
|
||||
action()
|
||||
}
|
||||
const abort = (): void =>
|
||||
finish(() => reject(createAbortError()))
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
finish(() =>
|
||||
reject(new Error('本地语音识别超时,请缩短录音后重试'))
|
||||
),
|
||||
TRANSCRIPTION_TIMEOUT_MS
|
||||
)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
worker.once(
|
||||
'message',
|
||||
(message: { ok?: boolean; text?: unknown }) => {
|
||||
if (message.ok && typeof message.text === 'string') {
|
||||
const text = message.text
|
||||
finish(() => resolve(text), false)
|
||||
} else {
|
||||
finish(
|
||||
() =>
|
||||
reject(
|
||||
new Error('本地语音识别失败,请重新安装模型后重试')
|
||||
),
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
worker.once('error', () =>
|
||||
finish(() =>
|
||||
reject(new Error('本地语音识别 Runtime 启动失败'))
|
||||
)
|
||||
)
|
||||
worker.once('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
finish(
|
||||
() =>
|
||||
reject(new Error('本地语音识别 Runtime 意外退出')),
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export class SpeechTranscriptionService {
|
||||
private readonly active = new Map<string, AbortController>()
|
||||
|
||||
constructor(
|
||||
private readonly models: SpeechModelResolver,
|
||||
private readonly runner: SpeechTranscriptionRunner =
|
||||
runSherpaTranscription
|
||||
) {}
|
||||
|
||||
async transcribe(input: unknown): Promise<SpeechTranscriptionResult> {
|
||||
const request = speechTranscriptionInputSchema.parse(input)
|
||||
if (this.active.has(request.requestId)) {
|
||||
throw new Error('该语音识别请求已在运行')
|
||||
}
|
||||
if (this.active.size > 0) {
|
||||
throw new Error('已有语音识别正在运行,请稍后重试')
|
||||
}
|
||||
const samples = new Float32Array(request.audio.slice(0))
|
||||
if (
|
||||
samples.some(
|
||||
(sample) =>
|
||||
!Number.isFinite(sample) || sample < -1 || sample > 1
|
||||
)
|
||||
) {
|
||||
throw new Error('录音采样数据无效')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
this.active.set(request.requestId, controller)
|
||||
try {
|
||||
const model = await this.models.getSelectedRuntimeModel()
|
||||
if (!model) {
|
||||
throw new Error('请先在设置中安装并选择本地语音模型')
|
||||
}
|
||||
const text = await this.runner(
|
||||
createSherpaRecognizerConfig(model),
|
||||
samples,
|
||||
request.sampleRate,
|
||||
controller.signal
|
||||
)
|
||||
return speechTranscriptionResultSchema.parse({ text })
|
||||
} finally {
|
||||
this.active.delete(request.requestId)
|
||||
}
|
||||
}
|
||||
|
||||
cancel(requestId: SpeechTranscriptionInput['requestId']): boolean {
|
||||
const parsedId = speechTranscriptionInputSchema.shape.requestId.parse(
|
||||
requestId
|
||||
)
|
||||
const controller = this.active.get(parsedId)
|
||||
if (!controller) {
|
||||
return false
|
||||
}
|
||||
controller.abort()
|
||||
return true
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const controller of this.active.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
this.active.clear()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user