Implémentation d'un Moteur de Reconnaissance Vocale Hors Ligne en Chinois avec Java et Vosk

Dans le cadre du développement de systèmes de contrôle vocal nécessitant une confidentialité stricte et une absence de dépendance au cloud, la reconnaissance vocale hors ligne est indispensable. La bibliothèque Vosk s'impose comme une solution de choix pour répondre à ce besoin. Elle est open-source, fonctionne sur des appraeils légers, propose une API de streaming et intègre des modèles pré-entraînés pour le mandarin.

Configuration de l'environnement Maven

Pour intégrer Vosk dans un projet Spring Boot, il est nécessaire d'ajouter le dépôt ofifciel d'Alpha Cephei, car les artefacts ne sont pas disponibles sur Maven Central. Nous utiliserons également Jackson pour le traitement JSON, qui est natif à Spring Boot.

<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
    </parent>
    
    <groupId>io.github.techvoice</groupId>
    <artifactId>offline-mandarin-asr</artifactId>
    <version>1.0.0</version>

    <repositories>
        <repository>
            <id>alphacephei-repo</id>
            <url>https://alphacephei.com/maven/</url>
        </repository>
    </repositories>

    <dependencies>
        <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>
        <dependency>
            <groupId>net.java.dev.jna</groupId>
            <artifactId>jna</artifactId>
            <version>5.13.0</version>
        </dependency>
    </dependencies>
</project>

Service de Traitement Audio Backend

Le cœur du traitement réside dans la conversion du flux audio et l'inférence du modèle. L'audio doit être rééchantillonné à 16 kHz, format exigé par le modèle Vosk pour une précision optimlae. L'API Java Sound est utilisée ici pour normaliser le flux entrant.

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.vosk.LibVosk;
import org.vosk.LogLevel;
import org.vosk.Model;
import org.vosk.Recognizer;

import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import java.io.File;

@Service
public class MandarinAsrService {

    private static final int TARGET_SAMPLE_RATE = 16000;
    private final ObjectMapper jsonMapper = new ObjectMapper();

    @Value("${asr.vosk.model.path}")
    private String modelDirectory;

    public String transcribeWav(File audioFile) throws Exception {
        LibVosk.setLogLevel(LogLevel.WARNINGS);

        try (Model model = new Model(modelDirectory);
             AudioInputStream sourceStream = AudioSystem.getAudioInputStream(audioFile)) {

            AudioFormat sourceFormat = sourceStream.getFormat();
            int channels = sourceFormat.getChannels();

            AudioFormat targetFormat = new AudioFormat(
                    sourceFormat.getEncoding(),
                    TARGET_SAMPLE_RATE,
                    sourceFormat.getSampleSizeInBits(),
                    channels,
                    sourceFormat.getFrameSize(),
                    sourceFormat.getFrameRate(),
                    sourceFormat.isBigEndian()
            );

            try (AudioInputStream resampledStream = AudioSystem.getAudioInputStream(targetFormat, sourceStream);
                 Recognizer recognizer = new Recognizer(model, TARGET_SAMPLE_RATE * channels)) {

                byte[] buffer = new byte[8192];
                int bytesRead;
                while ((bytesRead = resampledStream.read(buffer)) >= 0) {
                    recognizer.acceptWaveForm(buffer, bytesRead);
                }

                return extractTextFromJson(recognizer.getFinalResult());
            }
        }
    }

    private String extractTextFromJson(String jsonPayload) {
        if (jsonPayload == null || jsonPayload.isEmpty()) return "";
        try {
            JsonNode node = jsonMapper.readTree(jsonPayload);
            return node.has("text") ? node.get("text").asText().replaceAll("\\s+", "") : "";
        } catch (Exception e) {
            return "";
        }
    }
}

Contrôleur REST

Un point d'entrée API est exposé pour recevoir les fichiers audio téléversés par le client, les traiter via le service, et renvoyer la transcription.

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;

@RestController
@RequestMapping("/api/asr")
public class AsrController {

    private final MandarinAsrService asrService;

    public AsrController(MandarinAsrService asrService) {
        this.asrService = asrService;
    }

    @PostMapping("/transcribe")
    public ResponseEntity<String> handleAudioUpload(@RequestParam("audioFile") MultipartFile file) {
        File tempFile = null;
        try {
            tempFile = File.createTempFile("asr_audio_", ".wav");
            file.transferTo(tempFile);
            String transcription = asrService.transcribeWav(tempFile);
            return ResponseEntity.ok(transcription);
        } catch (Exception e) {
            return ResponseEntity.internalServerError().body("Erreur de traitement audio");
        } finally {
            if (tempFile != null && tempFile.exists()) {
                tempFile.delete();
            }
        }
    }
}

Interface Client et Capture Audio

Côté client, l'API Web Audio est utilisée pour capturer le microphone. Les données sont ensuite rééchantillonnées et encodées en format WAV avant l'envoi au serveur.


<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Interface de Reconnaissance Vocale</title>
</head>
<body>
    <h1>Contrôle Vocal Hors Ligne</h1>
    <button id="btnRecord">Démarrer</button>
    <button id="btnStop" disabled>Arrêter</button>
    <button id="btnSend" disabled>Analyser</button>
    <audio id="audioPlayback" controls></audio>
    <h2>Transcription :</h2>
    <p id="resultText">En attente...</p>
    <script src="audio-capture.js"></script>
</body>
</html>
class AudioCaptureManager {
    constructor() {
        this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
        this.scriptProcessor = null;
        this.mediaStreamSource = null;
        this.chunks = [];
        this.targetSampleRate = 16000;
    }

    async start() {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
        this.scriptProcessor = this.audioContext.createScriptProcessor(4096, 1, 1);

        this.mediaStreamSource.connect(this.scriptProcessor);
        this.scriptProcessor.connect(this.audioContext.destination);
        this.chunks = [];

        this.scriptProcessor.onaudioprocess = (event) => {
            this.chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
        };
    }

    stop() {
        if (this.scriptProcessor) {
            this.scriptProcessor.disconnect();
            this.mediaStreamSource.disconnect();
        }
    }

    generateWavBlob() {
        const mergedData = this.mergeBuffers();
        const downsampledData = this.downsample(mergedData, this.audioContext.sampleRate, this.targetSampleRate);
        return this.encodeWAV(downsampledData);
    }

    mergeBuffers() {
        let totalLength = this.chunks.reduce((acc, val) => acc + val.length, 0);
        const result = new Float32Array(totalLength);
        let offset = 0;
        for (let chunk of this.chunks) {
            result.set(chunk, offset);
            offset += chunk.length;
        }
        return result;
    }

    downsample(buffer, inputRate, outputRate) {
        if (inputRate === outputRate) return buffer;
        const ratio = inputRate / outputRate;
        const newLength = Math.round(buffer.length / ratio);
        const result = new Float32Array(newLength);
        let offsetResult = 0;
        let offsetBuffer = 0;
        
        while (offsetResult < result.length) {
            const nextOffsetBuffer = Math.round((offsetResult + 1) * ratio);
            let accum = 0, count = 0;
            for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
                accum += buffer[i];
                count++;
            }
            result[offsetResult] = accum / count;
            offsetResult++;
            offsetBuffer = nextOffsetBuffer;
        }
        return result;
    }

    encodeWAV(samples) {
        const buffer = new ArrayBuffer(44 + samples.length * 2);
        const view = new DataView(buffer);
        
        const writeString = (view, offset, string) => {
            for (let i = 0; i < string.length; i++) view.setUint8(offset + i, string.charCodeAt(i));
        };

        writeString(view, 0, 'RIFF');
        view.setUint32(4, 36 + samples.length * 2, true);
        writeString(view, 8, 'WAVE');
        writeString(view, 12, 'fmt ');
        view.setUint32(16, 16, true);
        view.setUint16(20, 1, true);
        view.setUint16(22, 1, true);
        view.setUint32(24, this.targetSampleRate, true);
        view.setUint32(28, this.targetSampleRate * 2, true);
        view.setUint16(32, 2, true);
        view.setUint16(34, 16, true);
        writeString(view, 36, 'data');
        view.setUint32(40, samples.length * 2, true);

        let offset = 44;
        for (let i = 0; i < samples.length; i++, offset += 2) {
            let s = Math.max(-1, Math.min(1, samples[i]));
            view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
        }
        return new Blob([view], { type: 'audio/wav' });
    }
}

const manager = new AudioCaptureManager();
const btnRecord = document.getElementById('btnRecord');
const btnStop = document.getElementById('btnStop');
const btnSend = document.getElementById('btnSend');
const audioPlayback = document.getElementById('audioPlayback');
const resultText = document.getElementById('resultText');
let currentBlob = null;

btnRecord.onclick = async () => {
    await manager.start();
    btnRecord.disabled = true;
    btnStop.disabled = false;
};

btnStop.onclick = () => {
    manager.stop();
    currentBlob = manager.generateWavBlob();
    audioPlayback.src = URL.createObjectURL(currentBlob);
    btnStop.disabled = true;
    btnSend.disabled = false;
};

btnSend.onclick = async () => {
    const formData = new FormData();
    formData.append('audioFile', currentBlob, 'recording.wav');
    resultText.innerText = "Traitement en cours...";
    try {
        const response = await fetch('/api/asr/transcribe', { method: 'POST', body: formData });
        const text = await response.text();
        resultText.innerText = text || "Aucun texte détecté";
    } catch (err) {
        resultText.innerText = "Erreur réseau";
    }
};

Étiquettes: Java Spring Boot Vosk Reconnaissance Vocale Web Audio API

Publié le 2 juin à 16h43