import { Injectable, Logger } from '@nestjs/common' import { execSync } from 'child_process' import * as path from 'path' import * as fs from 'fs' export interface AsrSegment { startTime: number endTime: number speaker: 'interviewer' | 'candidate' text: string } export interface AsrResult { fullText: string segments: AsrSegment[] duration: number } export interface AsrConfig { /** Path to whisper.cpp build/bin/ directory */ whisperCppPath?: string /** Model name: tiny | base | small | medium */ model?: string /** Language code (zh, en, auto) */ language?: string } @Injectable() export class AsrService { private readonly logger = new Logger(AsrService.name) private readonly whisperCppPath: string private readonly modelPath: string private readonly language: string private readonly cliPath: string constructor() { // Configuration via env vars with sensible defaults this.whisperCppPath = process.env.WHISPER_CPP_PATH || '/home/wlt/whisper.cpp' this.language = process.env.WHISPER_LANGUAGE || 'zh' const modelName = process.env.WHISPER_MODEL || 'base' this.modelPath = path.join(this.whisperCppPath, 'models', `ggml-${modelName}.bin`) this.cliPath = path.join(this.whisperCppPath, 'build', 'bin', 'whisper-cli') // Validate whisper.cpp installation on startup if (!fs.existsSync(this.cliPath)) { this.logger.warn(`whisper-cli not found at ${this.cliPath}. ASR will fall back to mock.`) } if (!fs.existsSync(this.modelPath)) { this.logger.warn(`Whisper model not found at ${this.modelPath}. ASR will fall back to mock.`) } } async transcribe(audioPath: string, _mimeType: string): Promise { this.logger.log(`Transcribing audio: ${audioPath}`) // Try companion .txt file first (for debugging/testing) const txtPath = audioPath.replace(/\.(mp3|m4a|wav|aac|ogg|mp4|webm)$/i, '.txt') try { if (fs.existsSync(txtPath)) { const text = fs.readFileSync(txtPath, 'utf-8') this.logger.log(`Found companion transcript: ${txtPath}`) return { fullText: text, segments: [{ startTime: 0, endTime: Math.max(text.length / 3.5, 10), speaker: 'candidate', text, }], duration: Math.max(text.length / 3.5, 10), } } } catch { /* ignore */ } // Try whisper.cpp if (fs.existsSync(this.cliPath) && fs.existsSync(this.modelPath)) { try { return await this.transcribeWithWhisper(audioPath) } catch (err: any) { this.logger.error(`whisper.cpp transcription failed: ${err.message}, falling back to mock`) } } // Fallback to mock this.logger.warn('Using MOCK ASR — whisper.cpp not available') return this.mockTranscribe() } private async transcribeWithWhisper(audioPath: string): Promise { // Ensure audio file exists if (!fs.existsSync(audioPath)) { throw new Error(`Audio file not found: ${audioPath}`) } // Convert to WAV if needed (whisper.cpp works best with WAV) const wavPath = await this.ensureWav(audioPath) // Run whisper-cli with JSON output const cmd = [ this.cliPath, '-m', this.modelPath, '-f', wavPath, '-l', this.language, '-oj', // JSON output '-t', String(Math.max(1, this.getCpuThreads())), // thread count '--no-prints', // suppress timing info on stderr ].join(' ') this.logger.log(`Running: ${this.cliPath} -m ${this.modelPath} -f ${wavPath} -l ${this.language}`) const stdout = execSync(cmd, { timeout: 600000, encoding: 'utf-8' }) // 10 min timeout // Parse the JSON output const segments = this.parseWhisperOutput(stdout) const fullText = segments.map(s => s.text).join(' ') const duration = segments.length > 0 ? segments[segments.length - 1].endTime : 0 return { fullText, segments, duration } } private parseWhisperOutput(stdout: string): AsrSegment[] { try { // whisper.cpp -oj outputs one JSON object per line const lines = stdout.trim().split('\n') const segments: AsrSegment[] = [] for (const line of lines) { try { const parsed = JSON.parse(line) if (parsed.text && parsed.offsets) { segments.push({ startTime: parsed.offsets.from / 1000, // ms to seconds endTime: parsed.offsets.to / 1000, speaker: 'candidate', text: parsed.text.trim(), }) } else if (parsed.text && parsed.start !== undefined) { // Alternative format segments.push({ startTime: parsed.start, endTime: parsed.end || parsed.start + 2, speaker: 'candidate', text: parsed.text.trim(), }) } } catch { /* skip unparseable lines */ } } if (segments.length > 0) return segments } catch { /* fall through */ } // Fallback: treat entire output as raw text this.logger.warn('Could not parse structured JSON output, using raw text') return [{ startTime: 0, endTime: 0, speaker: 'candidate', text: stdout.trim(), }] } /** * Convert audio to WAV format if needed. * Uses ffmpeg if available, otherwise returns original path. */ private async ensureWav(audioPath: string): Promise { const ext = path.extname(audioPath).toLowerCase() if (ext === '.wav') return audioPath const wavPath = audioPath.replace(/\.[^.]+$/, '.wav') try { execSync(`ffmpeg -y -i "${audioPath}" -ar 16000 -ac 1 -c:a pcm_s16le "${wavPath}"`, { timeout: 300000, encoding: 'utf-8', stdio: 'pipe', }) this.logger.log(`Converted ${audioPath} to WAV: ${wavPath}`) return wavPath } catch (err: any) { this.logger.warn(`ffmpeg conversion failed: ${err.message}. Trying original format.`) return audioPath } } private getCpuThreads(): number { try { return parseInt(process.env.WHISPER_THREADS || '', 10) || require('os').cpus().length || 4 } catch { return 4 } } /** Mock transcription for development/testing when whisper.cpp is not available */ private mockTranscribe(): AsrResult { const paragraphs = [ '我毕业于计算机科学与技术专业,大学期间主要学习了数据结构、算法、操作系统、计算机网络等核心课程。', '在项目经验方面,我参与过一个电商平台的开发,主要负责后端接口的设计和实现,使用了 Node.js 和 MongoDB 技术栈。', '这个项目的难点在于高并发场景下的性能优化,我通过引入 Redis 缓存和数据库索引优化,将接口响应时间从 2 秒降低到了 200 毫秒。', '关于这个岗位,我了解到贵公司主要使用 React 技术栈,我之前在两个项目中使用过 React,对 Hooks、状态管理、组件化开发都比较熟悉。', ] const fullText = paragraphs.join('\n') return { fullText, segments: [{ startTime: 0, endTime: 120, speaker: 'candidate', text: fullText, }], duration: 120, } } }