Final Project / 10

STT 集成与语音分析

前面所有文章处理的都是文字稿。但面试诊断的差异化价值在于:不只诊断“答了什么”,还诊断“怎么说的”

语气犹豫、停顿过长、填充词过多、语速失控——这些问题文字稿看不出来,只有从音频里才能提取。这一篇把音频处理管线从头搭起来:STT 转写 → 说话人分离 → 时间戳对齐 → 语音特征分析。

技术选型

组件 方案 说明
STT 转写 OpenAI Whisper API 精度高,支持中文,返回带时间戳的逐句转写
备选 STT FunASR(本地) 离线运行,免费,适合隐私敏感场景
说话人分离 pyannote-based / 启发式规则 区分面试官和候选人
音频预处理 ffmpeg 格式转换、降噪、分段

为什么同时支持两个 STT 方案?

  • Whisper API:精度最高、开发最快,但需要联网 + 费用
  • FunASR:完全离线、免费,但需要本地 Python 环境 + 精度略低

用户可以在配置中选择,Permission 层也会在调用 Whisper API 前确认(因为要上传音频到外部服务)。

模块结构

src/tools/
├── transcribe.ts          # STT 转写 Tool
├── detect-speakers.ts     # 说话人分离 Tool
└── analyze-speech.ts      # 语音特征分析 Tool(前面已实现)

src/audio/
├── preprocessor.ts        # 音频预处理(ffmpeg)
├── whisper.ts             # Whisper API 适配器
├── funasr.ts              # FunASR 本地适配器
├── speaker-detect.ts      # 说话人分离逻辑
├── types.ts               # 音频相关类型
└── utils.ts               # 工具函数

类型定义

// audio/types.ts

export interface TranscriptSegment {
  start: number;           // 开始时间(毫秒)
  end: number;             // 结束时间(毫秒)
  text: string;            // 转写文本
  confidence?: number;     // 置信度 0-1
  speaker?: string;        // 说话人标签
}

export interface TranscriptResult {
  fullText: string;                    // 完整转写文本
  segments: TranscriptSegment[];       // 带时间戳的逐句转写
  language: string;                    // 检测到的语言
  duration: number;                    // 总时长(毫秒)
}

export interface AudioMetadata {
  path: string;
  format: string;          // wav, mp3, m4a, ...
  sampleRate: number;
  channels: number;
  duration: number;        // 秒
  fileSize: number;        // bytes
}

export interface SpeakerSegment {
  speaker: 'interviewer' | 'candidate' | 'unknown';
  start: number;
  end: number;
  text: string;
}

export type STTProvider = 'whisper' | 'funasr';

音频预处理

面试录音格式五花八门(手机录的 m4a、电脑录的 wav、在线会议导出的 mp4)。统一转换为 Whisper 能处理的格式。

// audio/preprocessor.ts

import { execSync } from 'child_process';
import { statSync } from 'fs';
import { AudioMetadata } from './types';

export class AudioPreprocessor {
  /**
   * 探测音频元信息
   */
  probe(filePath: string): AudioMetadata {
    const output = execSync(
      `ffprobe -v quiet -print_format json -show_format -show_streams "${filePath}"`,
      { encoding: 'utf-8' }
    );
    const info = JSON.parse(output);
    const audioStream = info.streams.find((s: any) => s.codec_type === 'audio');

    return {
      path: filePath,
      format: info.format.format_name,
      sampleRate: parseInt(audioStream?.sample_rate ?? '16000'),
      channels: audioStream?.channels ?? 1,
      duration: parseFloat(info.format.duration),
      fileSize: statSync(filePath).size,
    };
  }

  /**
   * 转换为 Whisper 友好格式(16kHz mono WAV)
   */
  normalize(inputPath: string, outputPath: string): string {
    execSync(
      `ffmpeg -y -i "${inputPath}" -ar 16000 -ac 1 -f wav "${outputPath}"`,
      { stdio: 'pipe' }
    );
    return outputPath;
  }

  /**
   * 按时间分段(长录音切割,Whisper 有 25MB 限制)
   */
  split(inputPath: string, outputDir: string, segmentSeconds = 600): string[] {
    const metadata = this.probe(inputPath);
    const totalSeconds = metadata.duration;

    if (totalSeconds <= segmentSeconds) {
      return [inputPath];
    }

    const segments: string[] = [];
    for (let start = 0; start < totalSeconds; start += segmentSeconds) {
      const outputPath = `${outputDir}/segment_${start}.wav`;
      execSync(
        `ffmpeg -y -i "${inputPath}" -ss ${start} -t ${segmentSeconds} -ar 16000 -ac 1 "${outputPath}"`,
        { stdio: 'pipe' }
      );
      segments.push(outputPath);
    }

    return segments;
  }

  /**
   * 简单降噪(可选,对低质量录音有帮助)
   */
  denoise(inputPath: string, outputPath: string): string {
    // 使用 ffmpeg 的 anlmdn 滤镜做轻量降噪
    execSync(
      `ffmpeg -y -i "${inputPath}" -af "anlmdn=s=7:p=0.002:r=0.002" "${outputPath}"`,
      { stdio: 'pipe' }
    );
    return outputPath;
  }
}

Whisper API 适配器

// audio/whisper.ts

import OpenAI from 'openai';
import { createReadStream } from 'fs';
import { TranscriptResult, TranscriptSegment } from './types';

export class WhisperAdapter {
  private client: OpenAI;

  constructor(apiKey: string) {
    this.client = new OpenAI({ apiKey });
  }

  async transcribe(filePath: string, language = 'zh'): Promise<TranscriptResult> {
    const response = await this.client.audio.transcriptions.create({
      file: createReadStream(filePath),
      model: 'whisper-1',
      language,
      response_format: 'verbose_json',
      timestamp_granularities: ['segment'],
    });

    const segments: TranscriptSegment[] = (response as any).segments?.map((seg: any) => ({
      start: Math.round(seg.start * 1000),
      end: Math.round(seg.end * 1000),
      text: seg.text.trim(),
      confidence: seg.avg_logprob ? Math.exp(seg.avg_logprob) : undefined,
    })) ?? [];

    return {
      fullText: response.text,
      segments,
      language: (response as any).language ?? language,
      duration: segments.length > 0 ? segments[segments.length - 1].end : 0,
    };
  }

  /**
   * 处理长音频(分段转写 + 合并)
   */
  async transcribeLong(
    segmentPaths: string[],
    language = 'zh',
    onProgress?: (done: number, total: number) => void,
  ): Promise<TranscriptResult> {
    const allSegments: TranscriptSegment[] = [];
    let fullText = '';
    let timeOffset = 0;

    for (let i = 0; i < segmentPaths.length; i++) {
      const result = await this.transcribe(segmentPaths[i], language);

      // 偏移时间戳
      const offsetSegments = result.segments.map(seg => ({
        ...seg,
        start: seg.start + timeOffset,
        end: seg.end + timeOffset,
      }));

      allSegments.push(...offsetSegments);
      fullText += result.fullText + '\n';
      timeOffset += result.duration;

      onProgress?.(i + 1, segmentPaths.length);
    }

    return {
      fullText: fullText.trim(),
      segments: allSegments,
      language,
      duration: timeOffset,
    };
  }
}

成本估算:

Whisper API 定价: $0.006 / 分钟
一场 30 分钟面试: $0.18
一场 60 分钟面试: $0.36

FunASR 本地适配器

FunASR 是阿里开源的语音识别模型,支持完全离线运行。通过子进程调用 Python 脚本。

// audio/funasr.ts

import { execSync } from 'child_process';
import { readFileSync } from 'fs';
import { TranscriptResult, TranscriptSegment } from './types';

export class FunASRAdapter {
  private modelPath: string;
  private pythonBin: string;

  constructor(opts: { modelPath?: string; pythonBin?: string } = {}) {
    this.modelPath = opts.modelPath ?? '~/.cache/funasr/models';
    this.pythonBin = opts.pythonBin ?? 'python3';
  }

  async transcribe(filePath: string): Promise<TranscriptResult> {
    // 调用 Python 脚本执行 FunASR 推理
    const scriptPath = `${__dirname}/funasr_infer.py`;
    const outputPath = `/tmp/funasr_result_${Date.now()}.json`;

    execSync(
      `${this.pythonBin} "${scriptPath}" --input "${filePath}" --output "${outputPath}" --model-dir "${this.modelPath}"`,
      { timeout: 120000, stdio: 'pipe' }
    );

    const result = JSON.parse(readFileSync(outputPath, 'utf-8'));

    const segments: TranscriptSegment[] = result.sentences.map((s: any) => ({
      start: s.start,
      end: s.end,
      text: s.text,
      confidence: s.confidence,
    }));

    return {
      fullText: segments.map(s => s.text).join(''),
      segments,
      language: 'zh',
      duration: segments.length > 0 ? segments[segments.length - 1].end : 0,
    };
  }

  /**
   * 检查 FunASR 是否可用
   */
  isAvailable(): boolean {
    try {
      execSync(`${this.pythonBin} -c "import funasr"`, { stdio: 'pipe' });
      return true;
    } catch {
      return false;
    }
  }
}

配套的 Python 推理脚本:

# audio/funasr_infer.py

import argparse
import json
from funasr import AutoModel

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--input', required=True)
    parser.add_argument('--output', required=True)
    parser.add_argument('--model-dir', default='~/.cache/funasr/models')
    args = parser.parse_args()

    model = AutoModel(
        model="paraformer-zh",
        vad_model="fsmn-vad",
        punc_model="ct-punc",
        model_dir=args.model_dir,
    )

    result = model.generate(input=args.input, batch_size_s=300)

    # 转换为统一格式
    sentences = []
    for item in result:
        if 'sentence_info' in item:
            for sent in item['sentence_info']:
                sentences.append({
                    'start': sent['start'],
                    'end': sent['end'],
                    'text': sent['text'],
                    'confidence': sent.get('confidence', 0.9),
                })
        else:
            sentences.append({
                'start': 0,
                'end': 0,
                'text': item['text'],
                'confidence': 0.9,
            })

    with open(args.output, 'w', encoding='utf-8') as f:
        json.dump({'sentences': sentences}, f, ensure_ascii=False)

if __name__ == '__main__':
    main()

STT 转写 Tool 实现

// tools/transcribe.ts

import { Tool, ToolResult, ToolContext } from './types';
import { AudioPreprocessor } from '../audio/preprocessor';
import { WhisperAdapter } from '../audio/whisper';
import { FunASRAdapter } from '../audio/funasr';
import { TranscriptResult, STTProvider } from '../audio/types';

interface TranscribeInput {
  audioFilePath: string;
  language?: string;
  provider?: STTProvider;
  denoise?: boolean;
}

export const transcribeTool: Tool<TranscribeInput, TranscriptResult> = {
  name: 'transcribe_audio',
  description: '将面试录音转为带时间戳的文字稿。支持 Whisper API(在线)和 FunASR(离线)两种模式。',
  parameters: {
    type: 'object',
    properties: {
      audioFilePath: { type: 'string', description: '音频文件路径' },
      language: { type: 'string', description: '语言(默认 zh)', enum: ['zh', 'en'] },
      provider: { type: 'string', description: 'STT 引擎', enum: ['whisper', 'funasr'] },
      denoise: { type: 'boolean', description: '是否预降噪(默认 false)' },
    },
    required: ['audioFilePath'],
  },

  async execute(input, ctx): Promise<ToolResult<TranscriptResult>> {
    const { audioFilePath, language = 'zh', provider = 'whisper', denoise = false } = input;
    const preprocessor = new AudioPreprocessor();

    // 1. 探测音频信息
    let metadata;
    try {
      metadata = preprocessor.probe(audioFilePath);
    } catch {
      return { success: false, error: { code: 'input_error', message: `无法读取音频文件: ${audioFilePath}` } };
    }

    // 2. 预处理
    const normalizedPath = `/tmp/normalized_${Date.now()}.wav`;
    let processedPath = preprocessor.normalize(audioFilePath, normalizedPath);

    if (denoise) {
      const denoisedPath = `/tmp/denoised_${Date.now()}.wav`;
      processedPath = preprocessor.denoise(processedPath, denoisedPath);
    }

    // 3. 分段(如果超过 10 分钟)
    const segments = preprocessor.split(processedPath, '/tmp', 600);

    // 4. 转写
    let result: TranscriptResult;
    try {
      if (provider === 'whisper') {
        const whisper = new WhisperAdapter(process.env.OPENAI_API_KEY!);
        result = segments.length === 1
          ? await whisper.transcribe(segments[0], language)
          : await whisper.transcribeLong(segments, language);
      } else {
        const funasr = new FunASRAdapter();
        if (!funasr.isAvailable()) {
          return { success: false, error: { code: 'service_error', message: 'FunASR 未安装。请运行: pip install funasr' } };
        }
        result = await funasr.transcribe(processedPath);
      }
    } catch (err) {
      return { success: false, error: { code: 'service_error', message: `转写失败: ${err}` } };
    }

    return { success: true, data: result };
  },
};

说话人分离

面试录音至少有两个人说话:面试官和候选人。我们需要区分“谁在说什么”才能拆出 Q&A 对。

方案对比

方案 优点 缺点
pyannote(神经网络) 精度高 需要 GPU、Python 环境
基于时长启发式 无依赖、快速 精度低,不适合自由对话
基于内容的 LLM 分类 无额外模型 消耗 token、有延迟

推荐:启发式 + LLM 辅助(MVP 阶段不引入额外模型依赖)

// audio/speaker-detect.ts

import { TranscriptSegment, SpeakerSegment } from './types';

/**
 * 启发式说话人分离
 * 逻辑:面试中通常面试官说话短(提问),候选人说话长(回答)
 */
export function detectSpeakersHeuristic(segments: TranscriptSegment[]): SpeakerSegment[] {
  // Step 1: 按停顿分组(>2s 的间隔认为是换人)
  const groups = groupByPause(segments, 2000);

  // Step 2: 计算每组的时长
  const durations = groups.map(g => ({
    segments: g,
    totalDuration: g.reduce((sum, s) => sum + (s.end - s.start), 0),
    text: g.map(s => s.text).join(''),
  }));

  // Step 3: 启发式分类
  // - 短发言 + 问号结尾 → 面试官
  // - 长发言 → 候选人
  const avgDuration = durations.reduce((s, d) => s + d.totalDuration, 0) / durations.length;

  return durations.map(d => {
    const isQuestion = d.text.includes('') || d.text.includes('?') || d.text.endsWith('');
    const isShort = d.totalDuration < avgDuration * 0.5;

    const speaker = (isQuestion || isShort) ? 'interviewer' : 'candidate';

    return {
      speaker,
      start: d.segments[0].start,
      end: d.segments[d.segments.length - 1].end,
      text: d.text,
    } as SpeakerSegment;
  });
}

/**
 * LLM 辅助说话人分离(精度更高)
 */
export async function detectSpeakersWithLLM(
  segments: TranscriptSegment[],
  queryEngine: QueryEngine,
): Promise<SpeakerSegment[]> {
  // 先用启发式做初步分类
  const heuristic = detectSpeakersHeuristic(segments);

  // 对不确定的段落用 LLM 辅助判断
  const uncertain = heuristic.filter(s => s.speaker === 'unknown');
  if (uncertain.length === 0) return heuristic;

  const response = await queryEngine.query({
    task: 'split_qa',
    messages: [{
      role: 'user',
      content: `以下是面试录音的转写片段,请判断每段是面试官还是候选人在说话。输出JSON数组。

${uncertain.map((s, i) => `[${i}] "${s.text.slice(0, 100)}"`).join('\n')}`,
    }],
    systemPrompt: '你是面试录音的说话人分类器。面试官通常提问、追问,候选人通常回答、解释。只输出JSON。',
  });

  try {
    const labels = JSON.parse(response.content ?? '[]');
    for (let i = 0; i < uncertain.length && i < labels.length; i++) {
      uncertain[i].speaker = labels[i].speaker ?? 'candidate';
    }
  } catch {
    // LLM 输出解析失败,保持启发式结果
  }

  return heuristic;
}

function groupByPause(segments: TranscriptSegment[], pauseThreshold: number): TranscriptSegment[][] {
  const groups: TranscriptSegment[][] = [];
  let current: TranscriptSegment[] = [];

  for (let i = 0; i < segments.length; i++) {
    current.push(segments[i]);

    if (i < segments.length - 1) {
      const gap = segments[i + 1].start - segments[i].end;
      if (gap > pauseThreshold) {
        groups.push(current);
        current = [];
      }
    }
  }

  if (current.length > 0) groups.push(current);
  return groups;
}

说话人分离 Tool

// tools/detect-speakers.ts

import { Tool, ToolResult } from './types';
import { SpeakerSegment, TranscriptSegment } from '../audio/types';
import { detectSpeakersHeuristic, detectSpeakersWithLLM } from '../audio/speaker-detect';

interface DetectSpeakersInput {
  segments: TranscriptSegment[];
  method?: 'heuristic' | 'llm_assisted';
}

interface DetectSpeakersOutput {
  speakers: SpeakerSegment[];
  labeledTranscript: string;
  interviewerSegments: number;
  candidateSegments: number;
}

export const detectSpeakersTool: Tool<DetectSpeakersInput, DetectSpeakersOutput> = {
  name: 'detect_speakers',
  description: '对转写结果进行说话人分离,区分面试官和候选人。',
  parameters: {
    type: 'object',
    properties: {
      segments: { type: 'array', description: '带时间戳的转写分段' },
      method: { type: 'string', enum: ['heuristic', 'llm_assisted'], description: '分离方法' },
    },
    required: ['segments'],
  },

  async execute(input, ctx): Promise<ToolResult<DetectSpeakersOutput>> {
    const { segments, method = 'heuristic' } = input;

    let speakers: SpeakerSegment[];
    if (method === 'llm_assisted' && ctx.queryEngine) {
      speakers = await detectSpeakersWithLLM(segments, ctx.queryEngine);
    } else {
      speakers = detectSpeakersHeuristic(segments);
    }

    // 生成带标签的完整文本
    const labeledTranscript = speakers.map(s => {
      const label = s.speaker === 'interviewer' ? '面试官' : '候选人';
      return `${label}${s.text}`;
    }).join('\n\n');

    return {
      success: true,
      data: {
        speakers,
        labeledTranscript,
        interviewerSegments: speakers.filter(s => s.speaker === 'interviewer').length,
        candidateSegments: speakers.filter(s => s.speaker === 'candidate').length,
      },
    };
  },
};

语音特征分析的完整管线

把前面已实现的 analyze_speech Tool 和本篇的 STT + 说话人分离串联起来:

flowchart LR
    AUDIO[面试录音.m4a] --> PRE[预处理]
    PRE -->|16kHz WAV| SPLIT{>10min?}
    SPLIT -->|是| SEG[切分为 10min 段]
    SPLIT -->|否| STT[STT 转写]
    SEG --> STT
    STT -->|带时间戳的 segments| SPEAKER[说话人分离]
    SPEAKER -->|labeled segments| QA_SPLIT[拆分 Q&A 对]
    QA_SPLIT -->|per question| SPEECH[语音特征分析]

    SPEECH --> FLUENCY[流畅度]
    SPEECH --> PACE[语速]
    SPEECH --> FILLER[填充词]
    SPEECH --> PAUSE[停顿]

语音诊断报告输出示例

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  语音诊断 · 第 3 题
  "Agent 的记忆系统怎么设计"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

语音总分: 65/100

┌─────────┬────────┬────────────────────────────────┐
│ 维度     │ 得分   │ 详情                            │
├─────────┼────────┼────────────────────────────────┤
│ 流畅度   │ 58     │ 语速 180 字/分(偏慢)           │
│ 停顿     │ 70     │ 2 处长停顿(>3s)               │
│ 自信度   │ 55     │ 填充词 12 次(嗯×5 那个×4 就是×3)│
│ 节奏感   │ 78     │ 整体节奏尚可,后半段加速          │
└─────────┴────────┴────────────────────────────────┘

填充词热点:
  00:32 "嗯...那个..."
  00:45 "就是...就是那个..."
  01:12 "嗯..."

长停顿:
  00:28-00:32 (4.1s) ← 思考时间过长
  01:05-01:08 (3.2s)

建议:
  ① 回答前先用 1-2 秒组织框架,减少中途停顿
  ② "嗯/那个/就是"用沉默替代——短暂沉默比填充词更自信
  ③ 第一句就给结论,后面展开,避免"嗯...我觉得..."开头

配置项

// 在 SessionConfig 中新增音频相关配置

interface AudioConfig {
  sttProvider: 'whisper' | 'funasr';       // STT 引擎选择
  language: 'zh' | 'en';                    // 语言
  denoise: boolean;                         // 是否降噪
  speakerDetection: 'heuristic' | 'llm_assisted';  // 说话人分离方法
  fillerWords: string[];                    // 填充词列表(可自定义)
  longPauseThreshold: number;               // 长停顿阈值(ms)
  idealWpm: { min: number; max: number };   // 理想语速范围
}

const DEFAULT_AUDIO_CONFIG: AudioConfig = {
  sttProvider: 'whisper',
  language: 'zh',
  denoise: false,
  speakerDetection: 'heuristic',
  fillerWords: ['', '那个', '就是', '然后', '这个', '', '对对对'],
  longPauseThreshold: 2000,
  idealWpm: { min: 200, max: 280 },
};

隐私保护

音频是最敏感的数据类型。设计原则:

1. 录音不持久化
   - STT 转写完成后,临时 WAV 文件立即删除
   - 只保留文字稿(已是最小信息量)

2. Permission 层把关
   - 调用 Whisper API 前需要用户确认(音频将上传到 OpenAI)
   - FunASR 模式完全离线,无需确认

3. 文字稿可选脱敏
   - input-sanitize hook 可过滤人名、公司名
   - 用户可配置是否脱敏

4. 导出控制
   - 语音分析结果可导出
   - 但原始时间戳数据默认不包含在导出报告中

小结

  • 双 STT 引擎:Whisper API(精度高/联网)+ FunASR(离线/免费),用户可配置
  • 音频预处理:ffmpeg 做格式标准化 + 长音频分段 + 可选降噪
  • 说话人分离:启发式(短发言+问号=面试官)+ LLM 辅助,MVP 阶段不引入额外模型
  • 语音特征分析是纯算法(不需要 LLM):从时间戳计算语速/停顿/填充词/节奏
  • 隐私保护:录音处理后立即删除,Permission 层在联网 STT 前拦截确认
  • 成本:30 分钟面试 Whisper 转写 $0.18,FunASR 免费

下一篇建议继续看: