Skip to content
Open
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ tasks.withType<JacocoReport> {
"com/epam/brn/exception/**",
"com/epam/brn/Application*",
"com/epam/brn/service/azure/tts/config/**",
"com/epam/brn/service/yandex/tts/config/**",
"com/epam/brn/webclient/customizer/**",
"com/epam/brn/webclient/model/**",
)
Expand Down Expand Up @@ -251,6 +252,7 @@ sonarqube {
"**/com/epam/brn/service/load/FirebaseUserDataLoader*," +
"**/com/epam/brn/service/azure/tts/AzureVoiceLoader*," +
"**/com/epam/brn/service/azure/tts/config/**," +
"**/com/epam/brn/service/yandex/tts/config/**," +
"**/com/epam/brn/webclient/customizer/**," +
"**/com/epam/brn/webclient/model/**",
)
Expand Down
92 changes: 66 additions & 26 deletions frontend/make-words.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* global require */
const fs = require('fs');
const request = require('request');
const qs = require('querystring');
const https = require('https');

const words = `бам,сам,дам,зал,бум`;
const token = '';
Expand All @@ -12,31 +11,77 @@ const folderId = '';
// install ffmpeg

// yc iam create-token
// https://cloud.yandex.ru/docs/speechkit/tts/request
// https://cloud.yandex.ru/docs/speechkit/tts/v3/api-ref/grpc/

const yandex_tts_url =
'https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize?';
const yandex_tts_url = '/tts/v3/utteranceSynthesis';

function YandexTTS(options, callback) {
var params = {};

params['text'] = options['text'];
params['folderId'] = folderId;
params['format'] = 'oggopus';
params['lang'] = 'ru-RU';
params['voice'] = 'filipp';
params['emotion'] = 'good';

var full_url = yandex_tts_url + qs.stringify(params);
const body = JSON.stringify({
text: options['text'],
outputAudioSpec: {
containerAudio: {
containerAudioType: 'OGG_OPUS',
},
},
hints: [
{ voice: 'filipp' },
{ role: 'neutral' },
],
loudnessNormalizationType: 'LUFS',
});

var file = fs.createWriteStream(options['file']);
file.on('finish', callback);
request({
url: full_url,
const reqOptions = {
hostname: 'tts.api.cloud.yandex.net',
port: 443,
path: yandex_tts_url,
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Authorization': `Bearer ${token}`,
'x-folder-id': folderId,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}).pipe(file);
};

const file = fs.createWriteStream(options['file']);
const req = https.request(reqOptions, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200) {
console.error(`HTTP ${res.statusCode}: ${responseData}`);
file.end();
callback();
return;
}

const lines = responseData.split('\n').filter((line) => line.trim());
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (parsed.result && parsed.result.audioChunk && parsed.result.audioChunk.data) {
const audioBuffer = Buffer.from(parsed.result.audioChunk.data, 'base64');
file.write(audioBuffer);
}
} catch (e) {
// Ignore non-JSON transport lines.
}
}

file.end(callback);
});
});

req.on('error', (e) => {
console.error(`Request error: ${e.message}`);
file.end();
callback();
});

req.write(body);
req.end();
}

const execSync = require('child_process').execSync;
Expand Down Expand Up @@ -72,8 +117,3 @@ async function makeFiles() {
}

makeFiles();
// stack.forEach((word)=>{
// let file = word.trim();
// execSync(`gtts-cli "${word}." -lang_check --lang ru --output ${file}.mp3`);
// execSync(`ffmpeg-normalize ${file}.mp3 --normalization-type peak --target-level 0 -c:a libmp3lame -b:a 320k -o ${file}_n.mp3`)
// });
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.epam.brn.controller

import com.epam.brn.dto.request.audio.AudioVoiceOverrideRequest
import com.epam.brn.dto.response.BrnResponse
import com.epam.brn.dto.response.audio.AudioVoiceOptionResponse
import com.epam.brn.dto.response.audio.AudioVoiceSettingsResponse
import com.epam.brn.enums.BrnRole
import com.epam.brn.service.WordsService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import javax.annotation.security.RolesAllowed

@RestController
@RequestMapping("/audio")
@Tag(name = "Audio", description = "Contains actions for getting audio file for words")
@ConditionalOnProperty(name = ["default.tts.provider"], havingValue = "yandex")
@RolesAllowed(BrnRole.USER)
class YandexAudioSettingsController(
private val wordsService: WordsService,
) {
@GetMapping("/voices")
@Operation(summary = "Get available Yandex voices and the current runtime default for a locale")
fun getVoices(
@RequestParam(required = false, defaultValue = "ru-ru") locale: String,
): ResponseEntity<BrnResponse<AudioVoiceSettingsResponse>> = ResponseEntity
.ok()
.body(BrnResponse(data = buildVoiceSettingsResponse(locale)))

@PostMapping("/default-voice")
@Operation(summary = "Set the runtime default Yandex voice for a locale until the server restarts")
@RolesAllowed(BrnRole.ADMIN)
fun setDefaultVoice(
@RequestBody request: AudioVoiceOverrideRequest,
): ResponseEntity<BrnResponse<AudioVoiceSettingsResponse>> {
wordsService.setDefaultVoiceForLocale(request.locale, request.voice)
return ResponseEntity.ok().body(BrnResponse(data = buildVoiceSettingsResponse(request.locale)))
}

private fun buildVoiceSettingsResponse(locale: String): AudioVoiceSettingsResponse {
val defaultVoice = wordsService.getDefaultVoiceForLocale(locale)
val voiceOptions =
wordsService.getAvailableVoicesForLocale(locale).map { voice ->
AudioVoiceOptionResponse(
name = voice.name,
apiValue = voice.apiValue,
gender = voice.gender.name.lowercase(),
roles = voice.supportedRoles.map { it.apiValue },
isDefault = voice.name == defaultVoice,
)
}

return AudioVoiceSettingsResponse(
locale = locale.lowercase(),
defaultVoice = defaultVoice,
voices = voiceOptions,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.epam.brn.dto.request.audio

data class AudioVoiceOverrideRequest(
val locale: String,
val voice: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.epam.brn.dto.response.audio

data class AudioVoiceSettingsResponse(
val locale: String,
val defaultVoice: String,
val voices: List<AudioVoiceOptionResponse>,
)

data class AudioVoiceOptionResponse(
val name: String,
val apiValue: String,
val gender: String,
val roles: List<String>,
val isDefault: Boolean,
)
25 changes: 25 additions & 0 deletions src/main/kotlin/com/epam/brn/dto/yandex/tts/YandexTtsRequest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.epam.brn.dto.yandex.tts

import com.fasterxml.jackson.annotation.JsonInclude

data class YandexTtsRequest(
val text: String,
val outputAudioSpec: OutputAudioSpec,
val hints: List<Hint>,
val loudnessNormalizationType: String = "LUFS",
)

data class OutputAudioSpec(
val containerAudio: ContainerAudio,
)

data class ContainerAudio(
val containerAudioType: String = "OGG_OPUS",
)

@JsonInclude(JsonInclude.Include.NON_NULL)
data class Hint(
val voice: String? = null,
val speed: String? = null,
val role: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.epam.brn.dto.yandex.tts

import com.fasterxml.jackson.annotation.JsonIgnoreProperties

@JsonIgnoreProperties(ignoreUnknown = true)
data class YandexTtsResponse(
val result: YandexTtsResult? = null,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class YandexTtsResult(
val audioChunk: AudioChunk? = null,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioChunk(
val data: String? = null,
)
1 change: 0 additions & 1 deletion src/main/kotlin/com/epam/brn/enums/BrnLocale.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@ enum class BrnLocale(
) {
RU("ru-ru"),
EN("en-us"),
TR("tr-tr"),
}
75 changes: 58 additions & 17 deletions src/main/kotlin/com/epam/brn/enums/Voice.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,61 @@
package com.epam.brn.enums

// from docs: https://cloud.yandex.ru/docs/speechkit/tts/voices
enum class Voice {
FILIPP, // old Russian man voice
ALEXANDER, // new Russian man voice: neutral, good
KIRILL, // new Russian man voice: neutral, strict, good

OKSANA, // old Russian woman voice
MARINA, // new Russian woman voice
LERA, // new Russian woman voice
DASHA, // new Russian woman voice

NICK, // old English man voice
JOHN, // new English man voice
ALYSS, // old English woman voice

ERKANYAVAS, // old Turkish man voice
SILAERKAN, // old Turkish voice
// Based on the current Yandex SpeechKit TTS voices docs.
enum class Voice(
val locale: String,
val gender: VoiceGender,
vararg supportedRoles: VoiceRole,
) {
FILIPP(BrnLocale.RU.locale, VoiceGender.MALE, VoiceRole.NEUTRAL),
ERMIL(BrnLocale.RU.locale, VoiceGender.MALE, VoiceRole.NEUTRAL, VoiceRole.GOOD),
ZAHAR(BrnLocale.RU.locale, VoiceGender.MALE),
ALEXANDER(BrnLocale.RU.locale, VoiceGender.MALE, VoiceRole.NEUTRAL, VoiceRole.GOOD),
KIRILL(BrnLocale.RU.locale, VoiceGender.MALE, VoiceRole.NEUTRAL, VoiceRole.STRICT, VoiceRole.GOOD),

ALENA(BrnLocale.RU.locale, VoiceGender.FEMALE),
OKSANA(BrnLocale.RU.locale, VoiceGender.FEMALE),
MARINA(BrnLocale.RU.locale, VoiceGender.FEMALE, VoiceRole.FRIENDLY),
DASHA(BrnLocale.RU.locale, VoiceGender.FEMALE),
LERA(BrnLocale.RU.locale, VoiceGender.FEMALE),
JULIA(BrnLocale.RU.locale, VoiceGender.FEMALE),
MASHA(BrnLocale.RU.locale, VoiceGender.FEMALE),
MADI_RU(BrnLocale.RU.locale, VoiceGender.FEMALE),
OMAZH(BrnLocale.RU.locale, VoiceGender.FEMALE),

JOHN(BrnLocale.EN.locale, VoiceGender.MALE),
NICK(BrnLocale.EN.locale, VoiceGender.MALE),
JANE(BrnLocale.EN.locale, VoiceGender.FEMALE),
ALYSS(BrnLocale.EN.locale, VoiceGender.FEMALE),
;

val supportedRoles: List<VoiceRole> = supportedRoles.toList()

val apiValue: String
get() = name.lowercase()

companion object {
fun getVoicesForLocale(locale: String): List<Voice> = values().filter { it.locale == locale.lowercase() }

fun findByValue(value: String): Voice? = values().firstOrNull { it.name.equals(value, ignoreCase = true) }
}
}

enum class VoiceGender {
MALE,
FEMALE,
}

enum class VoiceRole {
NEUTRAL,
GOOD,
FRIENDLY,
STRICT,
;

val apiValue: String
get() = name.lowercase()

companion object {
fun findByValue(value: String): VoiceRole? = values().firstOrNull { it.name.equals(value, ignoreCase = true) }
}
}
Loading
Loading