Spring AI · Səs və şəkillərlə iş · Dərs 2

Mətndən nitqin yaradılması

Uyğun modellərlə səsi mətnə, mətni nitqə çevir. Şəkillər haqqında suallar ver və mətn sorğuları əsasında şəkillər yarat.

Mətndən nitqin yaradılması

Tələbə qısa izahı yoxladıqdan sonra Study Coach onu səsləndirə bilər. Nitq sintezi mətn qəbul edir və audio qaytarır. İzahın düzgünlüyünü yoxlamır; axıcı səs qeyri-müəyyən cavabı qəti həqiqət kimi göstərməməlidir.

1. Dinləmə seçimini istifadəçiyə ver

Mətn versiyası görünən qalsın və tələbə onu dinləmək istəyib-istəmədiyini seçsin. Real yaradılmış audio üçün pleyerdə “Süni intellektlə yaradılmış səs” işarəsini aydın göstər. Hazır sintetik səsi insan müəllim kimi təqdim etmə və müəyyən şəxsin onu yazdığını demə. Provayderin cari TTS təlimatı da bu məlumatın açıq göstərilməsini tələb edir. Provayderin nitq təlimatı

API-də model, səs, çıxış formatı və əlavə parametrlər ayrı seçimlərdir. Bir modeldə olan səs başqa provayderdə olmaya bilər. Dil dəstəyi Azərbaycan dilində texniki terminlərin düzgün tələffüzünə zəmanət vermir. Əlçatanlıq və ya keyfiyyət iddiasından əvvəl real dərsləri təmsil edən cümlələri yoxla.

Lokal stub qısa səssiz WAV qaytarır. Nitq yaratmadan, hesabdan ödəniş çıxmadan və dərs mətnini xarici xidmətə göndərmədən ikili cavab yolunu yoxlayır.

2. Mətn-səs API-sindən istifadə et

Ayrıca media laboratoriyasını davam etdir. src/main/java/academy/study/media/StudySpeech.java faylını yarat:

Java
package academy.study.media;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import org.springframework.ai.audio.tts.TextToSpeechPrompt;
import org.springframework.ai.openai.OpenAiAudioSpeechModel;
import org.springframework.ai.openai.OpenAiAudioSpeechOptions;
import org.springframework.ai.openai.api.OpenAiAudioApi;

public final class StudySpeech {
    private final OpenAiAudioSpeechModel model;

    public StudySpeech(URI localStub) {
        var options = OpenAiAudioSpeechOptions.builder().model("lab-speech").voice("alloy")
                .responseFormat(OpenAiAudioApi.SpeechRequest.AudioResponseFormat.WAV).speed(1.0).build();
        model = new OpenAiAudioSpeechModel(LocalMediaTransport.audio(localStub), options,
                LocalMediaTransport.once());
    }

    public byte[] speakReviewedText(String reviewedText) throws IOException {
        String text = MediaFiles.text(reviewedText, 1_000);
        var response = model.call(new TextToSpeechPrompt(text));
        if (response == null || response.getResult() == null || response.getResult().getOutput() == null) {
            throw new IllegalStateException("Speech unavailable");
        }
        return MediaFiles.wav(new ByteArrayInputStream(response.getResult().getOutput()));
    }
}

Spring AI 1.1.8-də sorğu tipi org.springframework.ai.audio.tts paketindəki TextToSpeechPrompt-dur. Cavabın nəticəsi byte[] saxlayır. Qaytarılan baytları MP3 fərz etmək əvəzinə WAV formatını və səsi açıq seçirik. Kod streaming audio deyil, bloklayan çağırış istifadə edir. Spring AI nitq API-si, nitq modelinin sabit versiyası

lab-speech uydurma stub modelidir. alloy sorğuda seçilən səs adıdır, amma stub həmin səsi yaratmır. Uğurlu test real modelin bütün seçimləri qəbul etdiyini göstərmir. Provayderin bəzi yeni imkanları bu Spring AI adapterində olmaya bilər; onları açmazdan əvvəl hər iki tərəfi yoxla.

3. Mətnin nəzərdən keçirilməsini giriş yoxlamasından ayır

speakReviewedText sorğudan əvvəl null, boş və uzun mətni rədd edir. Adı çağıran kodun məsuliyyətini bildirir; insanın mətni yoxladığını sübut etmir. Real UI səsləndirmə seçimini ekranda göstərilən dəqiq mətnə bağlamalı, onu səssizcə yeni model cavabı ilə əvəz etməməlidir.

1,000 kod vahidlik giriş həddi bir çağırışda göndərilən mətnin miqdarını azaldır. On saniyəlik səs yazısına zəmanət vermir. Nitqin müddəti məzmundan, dildən və danışıq sürətindən asılıdır. Metod qaytarılan WAV-ı əvvəlki on saniyəlik audio qaydasına görə ayrıca yoxlayır. Buna görə uzun, amma düzgün yaradılmış nitq provayder ödənişli işi gördükdən sonra rədd edilə bilər. Real məhsulda daha qısa təsdiqlənmiş parçalar və ölçülmüş hədlər istifadə et.

LocalMediaTransport.once() bu laboratoriyada avtomatik təkrar cəhdləri söndürür. Timeout-dan sonra yalnız exception-a baxaraq nitqin uzaq tərəfdə tamamlandığını bilmək olmaz. Təkrar cəhd qaydası əlavə ödənişləri və provayderin real idempotentlik davranışını nəzərə almalıdır; istənilən request ID-nin yalnız bir ödənişli əməliyyata zəmanət verdiyini düşünmə.

4. Məlum audio formatını təhlükəsiz qaytar

Cavab MediaFiles.wav metodundan keçir: açılmış real format, kanal sayı, nümunə eni, tezlik, frame sayı və bayt ölçüsü laboratoriya müqaviləsinə uyğun olmalıdır. Təkcə audio/wav cavab başlığı kifayət deyil. Yenidən kodlama çağıran tərəfə yoxlanmamış provayder məzmunu əvəzinə standartlaşdırılmış WAV verir.

Bu yoxlamaya qədər adapter cavabın baytlarını artıq yaddaşa alıb. Çıxış yoxlaması şəbəkə buferi həddi deyil və decoder üçün hər cür zərərli girişi dayandırmır. Real gateway və codec izolasiyası həmin hədləri ayrıca təmin etməlidir.

Metod baytlar qaytarır, fayl sistemi yolu seçmir. Tətbiq onları saxlayırsa, idarə olunan qovluqda serverin yaratdığı addan istifadə et, mövcud faylı əvəz etmə və çıxışı təsdiqlənmiş tələbə hesabına bağla. Brauzer cavabında audio/wav, neytral fayl adı və şəxsi mətn üçün uyğun private-cache qaydası olmalıdır. Səsləndirmə uğursuz olduqda mətn qalsın; audio xətasını uydurma uğur mesajı ilə əvəz etmə.

5. Sorğunu və audio konteynerini yoxla

src/test/java/academy/study/media/StudySpeechTest.java faylını yarat:

Java
package academy.study.media;

import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import javax.sound.sampled.AudioSystem;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class StudySpeechTest {
    private static final String PATH = "/v1/audio/speech";

    @Test
    void sendsReviewedTextAndParsesSyntheticWav() throws Exception {
        try (var stub = new MediaStub()) {
            stub.reply(PATH, 200, "audio/wav", MediaStub.wav(24_000, 2_400));
            byte[] audio = new StudySpeech(stub.base()).speakReviewedText("A class has one direct superclass.");
            var json = stub.lastJson();
            assertEquals("lab-speech", json.path("model").asText());
            assertEquals("alloy", json.path("voice").asText());
            assertEquals("wav", json.path("response_format").asText());
            assertEquals("A class has one direct superclass.", json.path("input").asText());
            try (var decoded = AudioSystem.getAudioInputStream(new ByteArrayInputStream(audio))) {
                assertEquals(24_000, decoded.getFormat().getSampleRate());
                assertEquals(2_400, decoded.getFrameLength());
            }
            assertEquals(1, stub.calls.get());
        }
    }

    @Test
    void invalidTextNeverReachesTheProvider() throws Exception {
        try (var stub = new MediaStub()) {
            var model = new StudySpeech(stub.base());
            for (String text : new String[]{null, " ", "x".repeat(1_001)}) {
                assertThrows(IllegalArgumentException.class, () -> model.speakReviewedText(text));
            }
            assertEquals(0, stub.calls.get());
        }
    }

    @Test
    void contentTypeDoesNotMakeErrorTextIntoAudio() throws Exception {
        try (var stub = new MediaStub()) {
            stub.reply(PATH, 200, "audio/wav", "not-audio".getBytes(StandardCharsets.UTF_8));
            assertThrows(IllegalArgumentException.class, () -> new StudySpeech(stub.base()).speakReviewedText("Study Java."));
        }
    }

    @Test
    void outputByteAndDurationCapsAreEnforced() throws Exception {
        for (byte[] output : new byte[][]{new byte[MediaFiles.MAX_WAV_BYTES + 1], MediaStub.wav(16_000, 160_001)}) {
            try (var stub = new MediaStub()) {
                stub.reply(PATH, 200, "audio/wav", output);
                assertThrows(IllegalArgumentException.class, () -> new StudySpeech(stub.base()).speakReviewedText("Study Java."));
            }
        }
    }

    @Test
    void failedSynthesisIsNotAutomaticallyRepeated() throws Exception {
        try (var stub = new MediaStub()) {
            stub.reply(PATH, 500, "text/plain", "private-synthesis-error".getBytes(StandardCharsets.UTF_8));
            var failure = assertThrows(IllegalStateException.class,
                    () -> new StudySpeech(stub.base()).speakReviewedText("Study Java."));
            assertEquals("Media request failed: HTTP 500", failure.getMessage());
            assertEquals(1, stub.calls.get());
        }
    }
}

Beş metod sorğunun mətn, model, səs və format sahələrini dəqiq yoxlayır, məlum səssiz WAV-ı açır, yanlış girişi HTTP-dən əvvəl rədd edir, aldadıcı content type-ı qəbul etmir, çıxış hədlərini tətbiq edir və server xətasında yalnız bir cəhd edildiyini göstərir. Test audiosu 24 kHz tezlikdə 2,400 frame saxlayır, yəni 0.1 saniyədir. Səs kimliyi, təbiiliyi və ya tələffüz barədə heç nə göstərmir.

6. Testi işə sal və uyğun nəticəni qiymətləndir

Nitq sinfini və testi əlavə etdikdən sonra işə sal:

sh
mvn -B -ntp -Dtest=StudyTranscriptionTest,StudySpeechTest test

Bu laboratoriyada on beş uğurlu metod gözlənilir. Maven asılılıqları və plugin-ləri keşləndikdən sonra -o istifadə et. Provayder açarı oxunmur. Növbəti dərs şəkil testlərini əlavə edir.

Tapşırıq: əvvəlki nitq sorğusu davam edərkən tələbə görünən izahı dəyişir. Pleyer hansı mətni oxuduğunu bildirməlidir?

Cavabı yoxla: audio nəticəsini göndərilmiş dəqiq mətnə və ya onun versiyasına bağla. Köhnə nəticəni köhnəlmiş kimi işarələ və ya at. Onu yeni izahın səsi kimi göstərmə. Bu, tətbiq vəziyyətinin idarə olunmasıdır; nitq modeli həmin problemi həll edə bilməz.

Real istifadədən əvvəl texniki sözləri, rəqəmləri, qısaltmaları və əlçatanlıq idarəetməsini nəzərdə tutulan istifadəçilərlə yoxla. Məhdud nümunələrlə gecikmə və xərci ölç, silinmə və saxlanma davranışını sənədləşdir. Qısa yoxlanmış cümlə kifayət edirsə, şəxsi söhbət tarixçəsini göndərmə.