Привет Хабр !

Не писал на Spring уже лет 8 и решил по фану написать мини пет проект с api и распознаванием речи. Звучит круто, лет 8-10 назад это заняло бы … вечность, тогда и llm, достаточно качественно распознающих русскую речь, да еще на скромном домашнем пк не было. В общем решил в выходной повеселиться.


Итого я собрал маленькое Spring Boot приложение, которое принимает короткий WAV-файл, отправляет его в локальную модель распознавания речи и показывает текст на странице.

Проект лежит на GitHub: https://github.com/rurikovich/RememberSpring


1. Что делает приложение

Сценарий простой:

  1. Открываем страницу http://localhost:8080/.

  2. Выбираем WAV-файл с русской речью.

  3. Нажимаем кнопку перевести в текст.

  4. Сервер принимает файл.

  5. Vosk распознаёт речь.

  6. Страница показывает результат.

Ограничение специально небольшое: аудио до 10 секунд. Для учебного проекта этого хватает.


2. На чём написано

Стек получился такой:

  • Java 21

  • Spring Boot 4

  • Spring Web

  • Vosk Java API

  • простая HTML-страница без React и прочего фронтенд-зоопарка

  • модель vosk-model-small-ru-0.22

Vosk подключается как Maven-зависимость:

pom.xml: зависимости
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>com.alphacephei</groupId>
    <artifactId>vosk</artifactId>
    <version>0.3.45</version>
</dependency>

Тут есть маленькая деталь: именно com.alphacephei:vosk, а не org.vosk:vosk.


3. Немного про модель

Для распознавания используется vosk-model-small-ru-0.22.

Это небольшая русская модель для Vosk. Она работает локально и не требует облачного API. То есть не нужны токены, аккаунты, лимиты запросов и оплата за каждую попытку.

Плюсы:

  • работает офлайн;

  • есть Java API;

  • легко положить рядом с учебным проектом;

  • для коротких фраз качество нормальное.

Минусы тоже есть:

  • это не большая современная модель уровня Whisper;

  • качество зависит от шума, микрофона и произношения;

  • модель занимает место в репозитории, если её коммитить.

В проекте модель лежит так:

Структура папки models
models/
  vosk-model-small-ru-0.22/
    am/
    conf/
    graph/
    ivector/
    README

4. Настройки приложения

В application.yaml задаём имя приложения, лимит загрузки файла и путь до модели.

application.yaml
spring:
  application:
    name: RememberSpring
  servlet:
    multipart:
      max-file-size: 25MB
      max-request-size: 25MB

asr:
  max-duration-seconds: 10
  vosk:
    model-path: ${VOSK_MODEL_PATH:models/vosk-model-small-ru-0.22}

5. REST-контроллер

Контроллер здесь простой. Он принимает файл, вызывает сервис распознавания и возвращает JSON.

TranscriptionController.java
@RestController
@RequestMapping("/api")
public class TranscriptionController {

    private final VoskTranscriptionService transcriptionService;

    public TranscriptionController(VoskTranscriptionService transcriptionService) {
        this.transcriptionService = transcriptionService;
    }

    @PostMapping(
            value = "/transcribe",
            consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ResponseEntity<TranscriptionResponse> transcribe(@RequestPart("file") MultipartFile file) {
        try {
            TranscriptionResult result = transcriptionService.transcribe(file);
            return ResponseEntity.ok(new TranscriptionResponse(
                    result.text(),
                    result.durationSeconds(),
                    "Vosk",
                    "ru"
            ));
        } catch (IllegalArgumentException e) {
            throw new ResponseStatusException(BAD_REQUEST, e.getMessage(), e);
        } catch (IllegalStateException e) {
            throw new ResponseStatusException(SERVICE_UNAVAILABLE, e.getMessage(), e);
        }
    }
}

Отдельно обрабатываются две ситуации:

  • пользователь прислал неправильный файл;

  • модель не найдена или не загрузилась.

Для учебного проекта этого достаточно. В боевом сервисе я бы добавил нормальный формат ошибок, request id и метрики.


6. Сервис распознавания

Основная работа находится в VoskTranscriptionService.

Что он делает:

  • проверяет, что файл не пустой;

  • лениво загружает модель Vosk;

  • читает WAV через Java Sound API;

  • приводит аудио к PCM 16 kHz mono;

  • проверяет длительность;

  • прогоняет байты через Recognizer;

  • достаёт поле text из ответа модели.

VoskTranscriptionService.java: главная часть
public synchronized TranscriptionResult transcribe(MultipartFile file) {
    if (file == null || file.isEmpty()) {
        throw new IllegalArgumentException("Файл пустой. Передайте короткий .wav файл.");
    }

    Model loadedModel = getOrLoadModel();
    try (
            AudioInputStream sourceStream = AudioSystem.getAudioInputStream(
                    new BufferedInputStream(file.getInputStream()));
            AudioInputStream pcmStream = AudioSystem.getAudioInputStream(TARGET_AUDIO_FORMAT, sourceStream);
            Recognizer recognizer = new Recognizer(loadedModel, TARGET_SAMPLE_RATE)
    ) {
        double durationSeconds = calculateDurationSeconds(sourceStream);
        if (durationSeconds > maxDurationSeconds) {
            throw new IllegalArgumentException(
                    "Файл длиннее " + maxDurationSeconds + " секунд. Укоротите запись и попробуйте снова.");
        }

        byte[] buffer = new byte[BUFFER_SIZE];
        int bytesRead;
        while ((bytesRead = pcmStream.read(buffer)) != -1) {
            if (bytesRead > 0) {
                recognizer.acceptWaveForm(buffer, bytesRead);
            }
        }

        String finalResultJson = recognizer.getFinalResult();
        return new TranscriptionResult(extractText(finalResultJson), durationSeconds);
    } catch (UnsupportedAudioFileException e) {
        throw new IllegalArgumentException("Неподдерживаемый формат аудио. Для демо используйте WAV.", e);
    } catch (IOException e) {
        throw new IllegalStateException("Не удалось прочитать аудиофайл.", e);
    }
}

Почему метод synchronized? Чтобы в маленьком учебном приложении не ловить одновременную работу нескольких Recognizer на одном сервисе и не усложнять код пулом задач. Для одного пользователя и демо-страницы нормально.

В продакшене я бы так не оставил. Там уже нужны очередь, worker-пул и ограничения по нагрузке.


7. HTML-страница

Фронтенд тут специально минимальный. Один index.html в src/main/resources/static.

Spring Boot сам отдаёт его по адресу http://localhost:8080/.

index.html: отправка файла
<form id="transcriptionForm">
    <label for="audioFile">Аудиофайл</label>
    <input id="audioFile" name="file" type="file" accept="audio/wav,.wav" required>
    <button id="submitButton" type="submit">перевести в текст</button>
</form>

<div id="result" class="result" hidden></div>
const formData = new FormData();
formData.append('file', fileInput.files[0]);

const response = await fetch('/api/transcribe', {
    method: 'POST',
    body: formData
});

const payload = await response.json();
showResult(payload.text || 'Модель не распознала текст.', false);

Без сборки фронтенда. Без npm. Без отдельного dev-server.

Для учебной статьи это плюс. Меньше магии вокруг, проще увидеть саму механику: файл ушёл на сервер, сервер вернул текст.


8. Как запустить

Клонируем проект:

Команды запуска
git clone https://github.com/rurikovich/RememberSpring.git
cd RememberSpring
./mvnw spring-boot:run

Потом открываем:

http://localhost:8080/

Выбираем WAV-файл до 10 секунд и нажимаем кнопку.

Если хочется проверить без страницы:

curl-запрос
curl -X POST "http://localhost:8080/api/transcribe" \
  -F "file=@sample.wav"

Ответ будет примерно такой:

Пример JSON-ответа
{
  "text": "тестовое сообщение для тестового проекта длиной пять секунд",
  "durationSeconds": 5.0,
  "engine": "Vosk",
  "language": "ru"
}

9. Что можно улучшить

Для учебного проекта всё ок. Но если делать нормальный сервис, я бы первым делом поменял несколько вещей.

Во-первых, не выполнять распознавание прямо в HTTP-запросе. Лучше принимать файл, отдавать jobId, а распознавание делать в фоне.

Во-вторых, добавить поддержку форматов через отдельный декодер. Сейчас демо работает только с WAV. А пользователь легко принесёт OGG, MP3 или что-то из мессенджера.

В-третьих, модель не всегда стоит коммитить в репозиторий. Для статьи это удобно: скачал проект, запустил, всё рядом. Для рабочего проекта модель лучше хранить как отдельный артефакт.

Ну и метрики. Без них не понятно: модель долго думает, файл плохой, запись шумная или сервер просто не тянет.


10. Итог

Получился маленький, но интересный пример:

  • Spring принимает файл;

  • Vosk распознаёт русскую речь локально;

  • HTML-страница показывает результат;

  • код можно запустить без облачных ключей.

Мне такие учебные проекты нравятся больше абстрактных примеров. Они не очень большие, но в них уже есть настоящие проблемы: лимит загрузки файла, путь до модели, формат аудио, обработка ошибок.


Если вам близки темы разработки, рефакторинга, архитектуры и стартапов буду рад видеть вас в моём Telegram-канале.

Веселых Вам Выходных !

Комментарии (0)