# desktop_subtitle
**Repository Path**: lukeewin/desktop_subtitle
## Basic Information
- **Project Name**: desktop_subtitle
- **Description**: Realtime ASR for Desktop Subtitle
- **Primary Language**: Java
- **License**: Apache-2.0
- **Default Branch**: master
- **Homepage**: https://blog.lukeewin.top/archives/java-desktop-subtitle
- **GVP Project**: No
## Statistics
- **Stars**: 3
- **Forks**: 1
- **Created**: 2025-08-29
- **Last Updated**: 2025-09-13
## Categories & Tags
**Categories**: Uncategorized
**Tags**: ASR, Javafx, RealtimeASR, paraformer
## README
# 1. 运行环境
本项目依赖 jdk8,并且只能运行在 Windows 系统中。如需服务器中部署实时语音识别接口,可联系微信 lukeewin01, 需要备注来自哪个平台,为何添加,否则不给通过。
# 2. 依赖
项目开源到 github 中,访问地址:https://github.com/lukeewin/desktop_subtitle
如果无法访问 github 的用户,可以访问 gitee: https://gitee.com/lukeewin/desktop_subtitle
这里使用 Maven 管理项目,在pom.xml中添加下方依赖。
```xml
4.0.0
lukeewin.desktop.subtitles
lukeewin
1.0-SNAPSHOT
org.yaml
snakeyaml
2.4
com.google.guava
guava
27.0.1-jre
io.netty
netty-all
4.1.111.Final
top.lukeewin
asr
1.12.9-jdk8
org.apache.maven.plugins
maven-compiler-plugin
2.3.2
1.8
1.8
```
注意:必须要使用 JDK8,如果使用其它版本的 JDK 会无法正常使用。并且只能在 Windows 系统中运行。如需在 Linux 服务器中部署 ASR 接口可以联系微信 lukeewin01
# 3. 项目核心代码解析
创建一个任务类 Task,专门用于获取电脑中的麦克风并且把语音信号实时输入到 Paraformer-Streaming 流式模型中。
Java 中获取本地电脑麦克风的代码如下:
```java
AudioFormat format = new AudioFormat(sampleRate, 16, 1, true, false);
DataLine.Info info = new DataLine.Info(TargetDataLine.class, format);
targetDataLine = (TargetDataLine) AudioSystem.getLine(info);
targetDataLine.open(format);
targetDataLine.start();
```
这里指定了麦克风的采样率为 16000,对应上面代码中的 sampleRate,位深为 16 bit,单声道,有符号,小端。
通过 JDK 中提供的方法获取麦克风规定的格式。然后调用 TargetDataLine 类中的 open() 方法,打开本地麦克风。
通过 TargetDataLine 中的 isOpen() 方法,判断麦克风是否正确打开,如果正常打开,就会把获取到的音频信息按照指定的 buffer 传入到 ASR 模型中进行解码。
```java
byte[] buffer = new byte[windowSize * 2];
float[] samples = new float[windowSize];
String lastResult = "";
while (targetDataLine.isOpen()) {
int n = targetDataLine.read(buffer, 0, buffer.length);
if (n <= 0) continue;
// PCM16LE 转 float
for (int i = 0; i != windowSize; ++i) {
int low = buffer[2 * i] & 0xFF;
int high = buffer[2 * i + 1];
short s = (short)((high << 8) | low);
samples[i] = (float) s / 32768;
}
for (int i = 0; i < samples.length; i++) {
samples[i] = samples[i] / 32768.0f;
}
stream.acceptWaveform(samples, sampleRate);
while (recognizer.isReady(stream)) {
recognizer.decode(stream);
}
}
```
模型返回的结果显示在 UI 界面中,并且还需要判断当前讲话是否结束,如果结束就重置。
```java
String text = recognizer.getResult(stream).getText();
boolean isEndpoint = recognizer.isEndpoint(stream);
if (!text.isEmpty() && !text.equals(lastResult)) {
lastResult = text;
String finalLastResult = lastResult;
Platform.runLater(() -> label.setText(finalLastResult));
}
if (isEndpoint) {
recognizer.reset(stream);
lastResult = "";
}
```
为了方便用户使用,方便修改字体大小和颜色,这里把字体大小和字体颜色配置到 application.yml 配置文件中,启动软件的时候会读取这个文件来设置字体大小和字体颜色,如果用户在运行中的软件修改了字体大小和颜色会改变这个文件,具体代码如下:
先创建一个实体类,作为配置字体对象。我把这个类命名为:FontConfigEntity
```java
package top.lukeewin.subtitle.entity;
/**
* @author Luke Ewin
* @date 2025/8/22 17:00
* @blog blog.lukeewin.top
*/
public class FontConfigEntity {
private int size;
private String color;
public FontConfigEntity() {
}
public FontConfigEntity(int size, String color) {
this.size = size;
this.color = color;
}
public int getSize() {
return size;
}
public void setSize(int size) {
this.size = size;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
}
```
然后在 Controller.java 中添加下面代码,实现右击弹出“设置”菜单栏功能。
```java
public void initialize(URL url, ResourceBundle rb) {
try {
Yaml yaml = new Yaml();
ASRConfigEntity asrConfigEntity = new ASRConfigEntity();
Map yamlMap = yaml.load(new FileInputStream("src/main/resources/application.yml"));
Map streamingASRConfigMap = (Map) yamlMap.get("streaming_asr");
String model_encoder = (String) streamingASRConfigMap.get("model_encoder");
String model_decoder = (String) streamingASRConfigMap.get("model_decoder");
String model_tokens = (String) streamingASRConfigMap.get("model_tokens");
asrConfigEntity.setModelEncoder(model_encoder);
asrConfigEntity.setModelDecoder(model_decoder);
asrConfigEntity.setModelTokens(model_tokens);
Map fontConfigMap = (Map) yamlMap.get("font");
int fontSize = (int) fontConfigMap.get("size");
String fontColor = (String) fontConfigMap.get("color");
fontConfig = new FontConfigEntity(fontSize, fontColor);
applyFontConfig();
Task task = new Task(label, asrConfigEntity);
new Thread(task).start();
createContextMenu();
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
}
private void createContextMenu() {
MenuItem settingsItem = new MenuItem("设置");
settingsItem.setOnAction(event -> openSettings());
final ContextMenu contextMenu = new ContextMenu();
contextMenu.getItems().addAll(settingsItem);
borderPane.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
if (event.getButton() == MouseButton.SECONDARY) {
contextMenu.show(borderPane, event.getScreenX(), event.getScreenY());
} else {
if (contextMenu.isShowing()) {
contextMenu.hide();
}
}
});
}
private void openSettings() {
// 打开设置窗口
SettingsDialog settingsDialog = new SettingsDialog(label.getScene().getWindow(), fontConfig);
FontConfigEntity newConfig = settingsDialog.showAndWait().orElse(null);
if (newConfig != null) {
this.fontConfig = newConfig;
applyFontConfig();
saveFontConfig();
}
}
private void applyFontConfig() {
label.setStyle("-fx-font-size: " + fontConfig.getSize() + "px; -fx-text-fill: " + fontConfig.getColor() + ";");
}
private void saveFontConfig() {
try {
Yaml yaml = new Yaml();
Map yamlMap = yaml.load(new FileInputStream("src/main/resources/application.yml"));
// 更新字体配置
Map fontConfigMap = new HashMap<>();
fontConfigMap.put("size", fontConfig.getSize());
fontConfigMap.put("color", fontConfig.getColor());
yamlMap.put("font", fontConfigMap);
// 写回文件
FileWriter writer = new FileWriter("src/main/resources/application.yml");
yaml.dump(yamlMap, writer);
writer.close();
} catch (Exception e) {
System.err.println("保存配置失败: " + e.getMessage());
}
}
```
上面代码中包含了从配置文件中加载模型路径的过程,把模型路径也配置到配置文件中方便管理。
这里主要使用到 snakeyaml 依赖实现读取和写入 application.yml 文件。
application.yml 配置文件如下:
```yaml
app: {name: Desktop_Subtitle, version: 1.0.0}
streaming_asr: {model_encoder: src/main/resources/models/streaming-paraformer-zh-en/encoder.int8.onnx,
model_decoder: src/main/resources/models/streaming-paraformer-zh-en/decoder.int8.onnx,
model_tokens: src/main/resources/models/streaming-paraformer-zh-en/tokens.txt}
asr: {model: src/main/resources/models/sense-voice-zh-en-ja-ko-yue/model.int8.onnx,
tokens: src/main/resources/models/sense-voice-zh-en-ja-ko-yue/tokens.txt}
sampleRate: 16000
windowSize: 512
font: {size: 20, color: '#FFB366'}
```
这里在代码层面是限制了字体大小范围:[10, 72],如果设置的值小于最小值,那么就会使用最小值,如果设置的值大于最大值,那么就会使用最大值,实现代码如下:
```java
fontSizeSpinner = new Spinner<>(10, 72, initialConfig.getSize());
SpinnerValueFactory valueFactory = fontSizeSpinner.getValueFactory();
if (valueFactory instanceof SpinnerValueFactory.IntegerSpinnerValueFactory) {
SpinnerValueFactory.IntegerSpinnerValueFactory intFactory = (SpinnerValueFactory.IntegerSpinnerValueFactory) valueFactory;
if (value < intFactory.getMin()) {
value = intFactory.getMin();
} else if (value > intFactory.getMax()) {
value = intFactory.getMax();
}
}
fontSizeSpinner.getValueFactory().setValue(value);
```
# 4. 项目演示
[如果想要看视频演示效果可以点击这里。](https://www.bilibili.com/video/BV1X7eYzUEsv/)