Files
smartDriveEE/src/main/java/com/rj/service/YihangyiVllmAsrService.java

222 lines
9.0 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String> 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<Path> 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<Path> listNewestPendingAudio(Path watchDir, int limit) {
if (!Files.isDirectory(watchDir) || limit <= 0) {
return List.of();
}
List<PathWithMtime> entries = new ArrayList<>();
try (Stream<Path> 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<String, Object> 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<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
try {
ResponseEntity<String> 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);
}
}