package com.rj.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.rj.config.YihangyiVllmAsrProperties; import com.rj.mapper.AudioManagementSegmentsMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.FileSystemResource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.stream.Stream; /** * 监视目录 → 重命名为 *processing* → 调用 OpenAI 兼容 /v1/audio/transcriptions → 写 txt → 移至完成目录 → 更新 {@code audio_file_path}。 */ @Slf4j @Service @RequiredArgsConstructor public class YihangyiVllmAsrService { private static final Set AUDIO_EXTENSIONS = Set.of( ".mp3", ".wav", ".m4a", ".flac", ".ogg", ".wma", ".aac", ".webm", ".opus" ); private final YihangyiVllmAsrProperties properties; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private final AudioManagementSegmentsMapper audioManagementSegmentsMapper; public int countPendingAudio(Path watchDir) { if (!Files.isDirectory(watchDir)) { return 0; } try (Stream stream = Files.list(watchDir)) { return (int) stream.filter(Files::isRegularFile).filter(YihangyiVllmAsrService::isPendingAudioFile).count(); } catch (IOException e) { log.warn("统计待转写文件失败: {}", watchDir.toAbsolutePath(), e); return 0; } } /** * 按修改时间从新到旧,最多 {@code limit} 条。 */ public List listNewestPendingAudio(Path watchDir, int limit) { if (!Files.isDirectory(watchDir) || limit <= 0) { return List.of(); } List entries = new ArrayList<>(); try (Stream stream = Files.list(watchDir)) { for (Path p : stream.filter(Files::isRegularFile).toList()) { if (!isPendingAudioFile(p)) { continue; } try { long mtime = Files.getLastModifiedTime(p).toMillis(); entries.add(new PathWithMtime(p, mtime)); } catch (IOException ignored) { // skip } } } catch (IOException e) { log.warn("列出待转写文件失败: {}", watchDir.toAbsolutePath(), e); return List.of(); } entries.sort(Comparator.comparingLong(PathWithMtime::mtime).reversed()); return entries.stream().limit(limit).map(PathWithMtime::path).toList(); } /** * 处理单个原始路径(调用前须已确认为待处理音频)。失败时打日志;转写失败则文件保持 *processing* 名(与 Python 一致)。 */ public void processOneFile(Path src) { Path watchDir = src.getParent(); if (watchDir == null) { log.warn("[skip] 无父目录: {}", src); return; } Path dst = processingPath(src); try { Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { log.warn("[skip] 重命名失败 {} -> {}: {}", src, dst, e.getMessage()); return; } long t0 = System.nanoTime(); try { String text = transcribe(dst); double elapsedSec = (System.nanoTime() - t0) / 1_000_000_000.0; log.info("ASR 完成 file={} 耗时={}s 字数={}", dst.getFileName(), String.format(Locale.ROOT, "%.3f", elapsedSec), text != null ? text.length() : 0); if (log.isDebugEnabled()) { log.debug("ASR 文本: {}", text); } Path txtDir = Path.of(properties.getTxtDir()); try { writeTranscriptionTxt(txtDir, src, text != null ? text : ""); } catch (IOException e) { log.error("[error] 转写成功但写入文本失败: {}", txtDir.resolve(txtFileName(src)), e); } Path doneDir = Path.of(properties.getDoneDir()); try { Files.createDirectories(doneDir); Path finalPath = doneDir.resolve(src.getFileName()); Files.move(dst, finalPath, StandardCopyOption.REPLACE_EXISTING); String abs = finalPath.toAbsolutePath().normalize().toString(); int n = audioManagementSegmentsMapper.updateAudioFilePathByAudioFileOriginalNameIgnoreTenant( abs, src.getFileName().toString()); if (n == 0) { log.warn("[warn] 未找到 audio_file_original_name={} 的记录,audio_file_path 未更新", src.getFileName()); } else { log.info("已更新 audio_file_path({} 行): {} -> {}", n, src.getFileName(), abs); } } catch (IOException e) { log.error("[error] 转写成功但移动失败 {} -> {}", dst, doneDir.resolve(src.getFileName()), e); } } catch (Exception e) { log.error("[error] 转写失败 {}", dst, e); } } private record PathWithMtime(Path path, long mtime) {} public static boolean isPendingAudioFile(Path path) { if (!Files.isRegularFile(path)) { return false; } String name = path.getFileName().toString(); String lower = name.toLowerCase(Locale.ROOT); int dot = lower.lastIndexOf('.'); String suf = dot < 0 ? "" : lower.substring(dot); if (!AUDIO_EXTENSIONS.contains(suf)) { return false; } return !lower.contains("processing"); } public static Path processingPath(Path path) { String stem = fileStem(path.getFileName().toString()); String extWithDot = fileExtensionWithDot(path.getFileName().toString()); return path.resolveSibling(stem + "processing" + extWithDot); } private static String fileStem(String fileName) { int dot = fileName.lastIndexOf('.'); return dot < 0 ? fileName : fileName.substring(0, dot); } private static String fileExtensionWithDot(String fileName) { int dot = fileName.lastIndexOf('.'); return dot < 0 ? "" : fileName.substring(dot); } private static String txtFileName(Path originalAudio) { return fileStem(originalAudio.getFileName().toString()) + ".txt"; } private void writeTranscriptionTxt(Path txtDir, Path originalAudio, String text) throws IOException { Files.createDirectories(txtDir); Path out = txtDir.resolve(txtFileName(originalAudio)); Files.writeString(out, text, StandardCharsets.UTF_8); } private String transcribe(Path audioPath) throws IOException { String base = properties.getBaseUrl().trim(); if (base.endsWith("/")) { base = base.substring(0, base.length() - 1); } String url = base + "/audio/transcriptions"; MultiValueMap body = new LinkedMultiValueMap<>(); body.add("model", properties.getModel()); body.add("file", new FileSystemResource(audioPath.toFile())); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); headers.setBearerAuth(properties.getApiKey() != null ? properties.getApiKey() : ""); HttpEntity> request = new HttpEntity<>(body, headers); try { ResponseEntity response = restTemplate.postForEntity(url, request, String.class); if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { throw new IOException("ASR HTTP 非成功: " + response.getStatusCode() + " body=" + response.getBody()); } return parseTranscriptionText(response.getBody()); } catch (RestClientException e) { throw new IOException("ASR 请求失败: " + e.getMessage(), e); } } private String parseTranscriptionText(String json) throws IOException { JsonNode root = objectMapper.readTree(json); JsonNode text = root.get("text"); if (text != null && !text.isNull()) { return text.asText(""); } throw new IOException("响应 JSON 缺少 text 字段: " + json); } }