SpringBoot + Whisper + FFmpeg:语音转文字服务接入,会议记录自动生成实战

语音转文字的痛点

在日常工作和项目开发中,你是否遇到过这样的场景:

  • 会议结束后,需要手动整理会议记录,费时费力
  • 录音文件格式不统一,难以处理
  • 语音识别准确率不高,需要大量人工修正
  • 需要处理各种音频格式,兼容性问题多

传统的人工整理方式不仅效率低下,还容易遗漏重要信息。现在有了AI语音识别技术,我们可以让这一切变得自动化。

解决方案思路

今天我们要解决的,就是如何用Whisper + FFmpeg构建一个高效的语音转文字服务。

核心思路是:

  1. 音频预处理:使用FFmpeg统一音频格式,提高识别质量
  2. 语音识别:使用Whisper模型进行高质量语音转文字
  3. 结果处理:对识别结果进行后处理和格式化
  4. 批量处理:支持批量音频文件转换

技术选型

  • SpringBoot:快速搭建应用
  • OpenAI Whisper:语音识别模型
  • FFmpeg:音频格式转换和预处理
  • Python:Whisper模型运行环境(或使用whisper.cpp优化版本)

核心实现思路

1. 环境准备

首先安装必要的工具:

# 安装FFmpeg
# Windows: 下载并添加到PATH
# Linux/Mac: apt-get install ffmpeg 或 brew install ffmpeg

# 安装Python依赖
pip install openai-whisper
# 或者使用whisper.cpp以获得更好的性能

2. 项目配置

在SpringBoot项目中添加必要的依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <!-- 文件上传处理 -->
    <dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
    </dependency>
</dependencies>

3. 音频预处理服务

创建音频预处理服务,使用FFmpeg标准化音频格式:

@Service
@Slf4j
public class AudioPreprocessingService {
    
    @Value("${audio.preprocess.path:/tmp/audio}")
    private String tempPath;
    
    /**
     * 使用FFmpeg预处理音频文件
     */
    public String preprocessAudio(String inputFilePath) throws IOException {
        // 创建临时文件
        File inputFile = new File(inputFilePath);
        String outputFileName = "preprocessed_" + System.currentTimeMillis() + ".wav";
        String outputPath = tempPath + "/" + outputFileName;
        
        // FFmpeg命令:转换为Whisper推荐的格式(16kHz, 单声道, WAV)
        String[] cmd = {
            "ffmpeg",
            "-i", inputFilePath,
            "-ar", "16000",  // 采样率16kHz
            "-ac", "1",      // 单声道
            "-c:a", "pcm_s16le", // 编码格式
            outputPath
        };
        
        ProcessBuilder processBuilder = new ProcessBuilder(cmd);
        Process process = processBuilder.start();
        
        try {
            int exitCode = process.waitFor();
            if (exitCode == 0) {
                log.info("音频预处理完成: {} -> {}", inputFilePath, outputPath);
                return outputPath;
            } else {
                throw new IOException("FFmpeg处理失败,退出码: " + exitCode);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IOException("音频处理被中断", e);
        }
    }
    
    /**
     * 验证音频文件基本信息
     */
    public AudioFileInfo getAudioInfo(String filePath) throws IOException {
        String[] cmd = {
            "ffprobe",
            "-v", "quiet",
            "-show_format",
            "-show_streams",
            "-print_format", "json",
            filePath
        };
        
        ProcessBuilder processBuilder = new ProcessBuilder(cmd);
        Process process = processBuilder.start();
        
        String result = new BufferedReader(
            new InputStreamReader(process.getInputStream())
        ).lines().collect(Collectors.joining("\n"));
        
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode rootNode = objectMapper.readTree(result);
        
        // 解析音频信息
        JsonNode streams = rootNode.get("streams");
        for (JsonNode stream : streams) {
            if ("audio".equals(stream.get("codec_type").asText())) {
                return AudioFileInfo.builder()
                    .duration(stream.get("duration").asDouble())
                    .sampleRate(stream.get("sample_rate").asInt())
                    .channels(stream.get("channels").asInt())
                    .codec(stream.get("codec_name").asText())
                    .build();
            }
        }
        
        return null;
    }
}

4. Whisper语音识别服务

创建语音识别服务,调用Whisper模型:

@Service
@Slf4j
public class WhisperTranscriptionService {
    
    @Value("${whisper.model.path:models/ggml-medium.bin}") // 使用whisper.cpp模型
    private String modelPath;
    
    @Value("${whisper.executable.path:./whisper/whisper}")
    private String whisperExecutable;
    
    /**
     * 使用Whisper进行语音识别
     */
    public TranscriptionResult transcribeAudio(String audioFilePath, String language) {
        try {
            String outputFileName = "transcript_" + System.currentTimeMillis();
            String outputPath = System.getProperty("java.io.tmpdir") + "/" + outputFileName;
            
            // 构建Whisper命令
            List<String> cmd = new ArrayList<>();
            cmd.add(whisperExecutable);
            cmd.add("--model");
            cmd.add(modelPath);
            cmd.add("--output-txt");
            cmd.add("--output-file");
            cmd.add(outputPath);
            cmd.add("--language");
            cmd.add(language != null ? language : "zh"); // 默认中文
            cmd.add(audioFilePath);
            
            ProcessBuilder processBuilder = new ProcessBuilder(cmd);
            Process process = processBuilder.start();
            
            // 等待处理完成
            int exitCode = process.waitFor();
            if (exitCode != 0) {
                log.error("Whisper处理失败,退出码: {}", exitCode);
                return TranscriptionResult.failure("语音识别失败,退出码: " + exitCode);
            }
            
            // 读取识别结果
            String txtFilePath = outputPath + ".txt";
            String transcript = readFileToString(txtFilePath);
            
            log.info("语音识别完成: {}", audioFilePath);
            return TranscriptionResult.success(transcript);
            
        } catch (Exception e) {
            log.error("语音识别过程出错", e);
            return TranscriptionResult.failure("语音识别失败: " + e.getMessage());
        }
    }
    
    /**
     * 批量处理音频文件
     */
    public List<TranscriptionResult> batchTranscribe(List<String> audioFiles, String language) {
        return audioFiles.parallelStream()
                .map(filePath -> transcribeAudio(filePath, language))
                .collect(Collectors.toList());
    }
    
    private String readFileToString(String filePath) throws IOException {
        return new String(Files.readAllBytes(Paths.get(filePath)), StandardCharsets.UTF_8);
    }
}

5. 会议记录生成服务

创建会议记录生成和处理服务:

@Service
@Slf4j
public class MeetingRecordService {
    
    @Autowired
    private AudioPreprocessingService preprocessingService;
    
    @Autowired
    private WhisperTranscriptionService transcriptionService;
    
    /**
     * 生成会议记录
     */
    public MeetingRecord generateMeetingRecord(MultipartFile audioFile, String meetingTitle) {
        try {
            // 1. 保存上传的音频文件
            String originalFilePath = saveUploadedFile(audioFile);
            
            // 2. 预处理音频
            String processedFilePath = preprocessingService.preprocessAudio(originalFilePath);
            
            // 3. 语音识别
            TranscriptionResult result = transcriptionService.transcribeAudio(processedFilePath, "zh");
            
            if (!result.isSuccess()) {
                throw new RuntimeException("语音识别失败: " + result.getErrorMessage());
            }
            
            // 4. 生成会议记录
            MeetingRecord record = new MeetingRecord();
            record.setTitle(meetingTitle);
            record.setOriginalAudioPath(originalFilePath);
            record.setProcessedAudioPath(processedFilePath);
            record.setRawTranscript(result.getText());
            record.setProcessedTranscript(postProcessTranscript(result.getText()));
            record.setCreatedAt(LocalDateTime.now());
            
            // 5. 清理临时文件
            cleanupTempFiles(processedFilePath);
            
            return record;
            
        } catch (Exception e) {
            log.error("生成会议记录失败", e);
            throw new RuntimeException("会议记录生成失败: " + e.getMessage());
        }
    }
    
    /**
     * 后处理识别结果
     */
    private String postProcessTranscript(String rawTranscript) {
        // 移除时间戳
        String processed = rawTranscript.replaceAll("\\[\\d{2}:\\d{2}.\\d{3} --> \\d{2}:\\d{2}.\\d{3}\\]", "");
        
        // 清理多余的空白字符
        processed = processed.replaceAll("\\s+", " ").trim();
        
        // 按句子分割,便于后续处理
        String[] sentences = processed.split("[。!?.!?]");
        
        StringBuilder formatted = new StringBuilder();
        for (String sentence : sentences) {
            sentence = sentence.trim();
            if (!sentence.isEmpty()) {
                formatted.append(sentence).append("。\n");
            }
        }
        
        return formatted.toString();
    }
    
    private String saveUploadedFile(MultipartFile file) throws IOException {
        String fileName = "audio_" + System.currentTimeMillis() + "_" + file.getOriginalFilename();
        String filePath = System.getProperty("java.io.tmpdir") + "/" + fileName;
        
        try (FileOutputStream fos = new FileOutputStream(filePath)) {
            file.transferTo(fos);
        }
        
        return filePath;
    }
    
    private void cleanupTempFiles(String... filePaths) {
        for (String filePath : filePaths) {
            try {
                Files.deleteIfExists(Paths.get(filePath));
            } catch (IOException e) {
                log.warn("删除临时文件失败: {}", filePath, e);
            }
        }
    }
}

6. REST API接口

提供API接口供前端或其他服务调用:

@RestController
@RequestMapping("/api/meeting-record")
public class MeetingRecordController {
    
    @Autowired
    private MeetingRecordService meetingRecordService;
    
    /**
     * 上传音频文件生成会议记录
     */
    @PostMapping("/generate")
    public ResponseEntity<Result<MeetingRecord>> generateRecord(
            @RequestParam("audio") MultipartFile audioFile,
            @RequestParam(value = "title", required = false) String title) {
        
        try {
            if (audioFile.isEmpty()) {
                return ResponseEntity.badRequest()
                    .body(Result.error("音频文件不能为空"));
            }
            
            // 验证文件类型
            String contentType = audioFile.getContentType();
            if (!isValidAudioFormat(contentType)) {
                return ResponseEntity.badRequest()
                    .body(Result.error("不支持的音频格式,请上传mp3, wav, m4a等格式"));
            }
            
            MeetingRecord record = meetingRecordService.generateMeetingRecord(
                audioFile, 
                title != null ? title : "会议记录_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
            );
            
            return ResponseEntity.ok(Result.success(record));
            
        } catch (Exception e) {
            log.error("生成会议记录失败", e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Result.error("生成会议记录失败: " + e.getMessage()));
        }
    }
    
    /**
     * 批量处理会议录音
     */
    @PostMapping("/batch-generate")
    public ResponseEntity<Result<List<MeetingRecord>>> batchGenerateRecords(
            @RequestParam("audioFiles") MultipartFile[] audioFiles) {
        
        List<MeetingRecord> records = new ArrayList<>();
        
        for (MultipartFile file : audioFiles) {
            try {
                MeetingRecord record = meetingRecordService.generateMeetingRecord(
                    file, 
                    "批量处理_" + file.getOriginalFilename()
                );
                records.add(record);
            } catch (Exception e) {
                log.error("处理音频文件失败: {}", file.getOriginalFilename(), e);
            }
        }
        
        return ResponseEntity.ok(Result.success(records));
    }
    
    /**
     * 获取会议记录详情
     */
    @GetMapping("/{id}")
    public ResponseEntity<Result<MeetingRecord>> getRecord(@PathVariable Long id) {
        // 实现获取记录逻辑
        return ResponseEntity.ok(Result.success(null)); // 简化实现
    }
    
    private boolean isValidAudioFormat(String contentType) {
        return contentType != null && (
            contentType.startsWith("audio/") ||
            contentType.equals("video/mp4") ||  // MP4也包含音频
            contentType.equals("video/x-msvideo") // AVI也包含音频
        );
    }
}

7. 任务队列和异步处理

对于大文件或批量处理,使用异步处理:

@Service
@Slf4j
public class AsyncTranscriptionService {
    
    @Autowired
    private MeetingRecordService meetingRecordService;
    
    @Async
    @EventListener
    public void handleTranscriptionRequest(TranscriptionEvent event) {
        try {
            MeetingRecord record = meetingRecordService.generateMeetingRecord(
                event.getAudioFile(), 
                event.getTitle()
            );
            
            // 发送完成事件
            applicationEventPublisher.publishEvent(
                new TranscriptionCompletedEvent(record, event.getCallbackUrl())
            );
            
        } catch (Exception e) {
            log.error("异步转录失败", e);
            applicationEventPublisher.publishEvent(
                new TranscriptionFailedEvent(event.getOriginalRequestId(), e.getMessage())
            );
        }
    }
}

性能优化策略

1. 缓存机制

@Service
public class CachedTranscriptionService {
    
    @Cacheable(value = "transcriptions", key = "#audioFilePath + '_' + #language")
    public TranscriptionResult getTranscription(String audioFilePath, String language) {
        return transcriptionService.transcribeAudio(audioFilePath, language);
    }
}

2. 并发处理

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("transcription-");
        executor.initialize();
        return executor;
    }
}

优势分析

相比传统的人工整理方式,这种方案的优势明显:

  1. 效率提升:几小时内会议内容几分钟内完成转录
  2. 准确性高:现代语音识别模型准确率可达90%以上
  3. 格式统一:自动输出标准化的会议记录格式
  4. 批量处理:支持批量音频文件处理
  5. 多语言支持:支持多种语言的语音识别

注意事项

  1. 硬件要求:语音识别需要一定的计算资源
  2. 音频质量:原始音频质量直接影响识别准确率
  3. 隐私安全:敏感会议内容建议本地处理
  4. 网络依赖:如果是在线API,需要稳定的网络连接
  5. 模型选择:根据准确率和性能需求选择合适的模型

总结

通过SpringBoot + Whisper + FFmpeg的技术组合,我们可以构建一个高效、准确的语音转文字服务。这不仅能大幅提升会议记录整理效率,还能释放人力资源去做更有价值的工作。

在实际项目中,建议根据具体需求调整模型参数和处理流程,以达到最佳的准确率和性能平衡。


服务端技术精选,专注分享后端开发实战技术,助力你的技术成长!


标题:SpringBoot + Whisper + FFmpeg:语音转文字服务接入,会议记录自动生成实战
作者:jiangyi
地址:http://jiangyi.space/articles/2026/01/10/1768029360917.html

    0 评论
avatar