commit 12/10/2025

This commit is contained in:
2025-12-10 10:59:22 +07:00
parent 802cf940a9
commit cbca8cd43d
10 changed files with 403 additions and 4 deletions

View File

@@ -592,6 +592,17 @@ $(document).ready(function () {
} }
}); });
}) })
$('#ttsgenerator').click(() => {
sidemenu.hide();
$('#content').load('tts.html', function (response, status, xhr) {
if (status === "success") {
console.log("TTS Generator content loaded successfully");
// pindah ke ttsgenerator.js
} else {
console.error("Error loading TTS Generator content:", xhr.status, xhr.statusText);
}
});
});
$('#filemanagement').click(() => { $('#filemanagement').click(() => {
sidemenu.hide(); sidemenu.hide();
$('#content').load('filemanagement.html', function (response, status, xhr) { $('#content').load('filemanagement.html', function (response, status, xhr) {

View File

@@ -0,0 +1,78 @@
function websocket_init() {
window.addEventListener('ws_connected', () => {
console.log("tts.js ws_connected event triggered");
});
window.addEventListener('ws_disconnected', () => {
console.log("tts.js ws_disconnected event triggered");
});
window.addEventListener('ws_message', (event) => {
let rep = event.detail;
let cmd = rep.reply;
let data = rep.data;
if (cmd && cmd.length > 0) {
switch (cmd) {
case "start_generate_tts":
if (data && data.length>0 && "ok"===data){
add_to_list("TTS generation started.");
$('#startstopgeneration').text("Stop Generating");
}
break;
case "stop_generate_tts":
if (data && data.length>0 && "ok"===data){
add_to_list("TTS generation stopped.");
$('#startstopgeneration').text("Start Generating");
}
break;
case "tts_generate_progress":
if (data && data.length>0){
let xx = JSON.parse(data);
if ("progress" in xx){
if ("message" in xx){
add_to_list(xx.message + "(" + xx.progress+" %)");
}
}
}
}
}
});
}
function add_to_list(message){
// add message to generatelogs list, with date time prefix
let li = document.createElement("li");
let now = new Date();
let datetime = now.toLocaleString();
li.appendChild(document.createTextNode("[" + datetime + "] " + message));
$('#generatelogs').append(li);
}
$(document).ready(function () {
console.log("TTS module loaded.");
websocket_init();
$('#uploadjson').on('click', function () {
// TODO upload JSON file and process it
});
$('#startstopgeneration').on('click', function () {
if ($('#startstopgeneration').text() === "Start Generating") {
// clear list generatelogs
$('#generatelogs').empty();
let data = {
voicetype: $('#voicetype').val(),
languagetogenerate: $('#languagetogenerate').val(),
databasesource: $('#databasesource').val(),
targetas: $('#targetas').val(),
fileoperation: $('input[name="fileoperation"]:checked').val(),
autoadd: $('input[name="autoadd"]:checked').val()
}
sendCommand("start_generate_tts", JSON.stringify(data));
} else {
sendCommand("stop_generate_tts", "");
}
});
});

View File

@@ -88,6 +88,19 @@
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"></path> <path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"></path>
</g> </g>
</svg>&nbsp;Setting</a></li> </svg>&nbsp;Setting</a></li>
<li class="nav-item"><a class="nav-link link-body-emphasis text-menu" id="ttsgenerator" href="#"><svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="1em" viewBox="0 0 24 24" width="1em" fill="currentColor" class="me-2 icon-menu pad-icon-menu" style="font-size: 20px;">
<g>
<rect fill="none" height="24" width="24"></rect>
</g>
<g>
<g>
<circle cx="10" cy="9" r="4"></circle>
<path d="M16.39,15.56C14.71,14.7,12.53,14,10,14c-2.53,0-4.71,0.7-6.39,1.56C2.61,16.07,2,17.1,2,18.22V21h16v-2.78 C18,17.1,17.39,16.07,16.39,15.56z"></path>
<path d="M20.36,1l-1.41,1.41c2.73,2.73,2.73,7.17,0,9.9l1.41,1.41C23.88,10.21,23.88,4.51,20.36,1z"></path>
<path d="M17.54,10.9c1.95-1.95,1.95-5.12,0-7.07l-1.41,1.41c1.17,1.17,1.17,3.07,0,4.24L17.54,10.9z"></path>
</g>
</g>
</svg>&nbsp;Text To Speech Generator</a></li>
<li class="nav-item"><a class="nav-link link-body-emphasis text-menu" id="logoutlink" href="#"><svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 24 24" width="1em" fill="currentColor" class="me-2 icon-menu" style="font-size: 20px;"> <li class="nav-item"><a class="nav-link link-body-emphasis text-menu" id="logoutlink" href="#"><svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 24 24" width="1em" fill="currentColor" class="me-2 icon-menu" style="font-size: 20px;">
<path d="M0 0h24v24H0z" fill="none"></path> <path d="M0 0h24v24H0z" fill="none"></path>
<path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"></path> <path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"></path>

129
html/webpage/tts.html Normal file
View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>AAS_NewGen_28OKT25rev1</title>
<link rel="stylesheet" href="assets/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="assets/css/Font%20Awesome%205%20Brands.css">
<link rel="stylesheet" href="assets/css/Font%20Awesome%205%20Duotone.css">
<link rel="stylesheet" href="assets/css/Font%20Awesome%205%20Pro.css">
<link rel="stylesheet" href="assets/css/Font%20Awesome%206%20Brands.css">
<link rel="stylesheet" href="assets/css/Font%20Awesome%206%20Duotone.css">
<link rel="stylesheet" href="assets/css/Font%20Awesome%206%20Pro.css">
<link rel="stylesheet" href="assets/css/FontAwesome.css">
<link rel="stylesheet" href="assets/css/bss-overrides.css">
<link rel="stylesheet" href="assets/css/Login-Form-Basic-icons.css">
<link rel="stylesheet" href="assets/css/styles.css">
</head>
<body>
<div class="row">
<div class="col w-100 h-100 pad-header">
<h2 style="text-align: center;">Text To Speech Content Generator</h2>
</div>
</div>
<div class="row">
<div class="col">
<div class="card card-setting"></div>
</div>
</div>
<div class="row">
<div class="col">
<div class="card card-setting">
<div class="card-body pad-accordion">
<h4 class="card-title">Google Parameter</h4>
<hr>
<div class="row">
<div class="col-3 h-100"><label class="col-form-label">Application Credential JSON</label></div>
<div class="col"><input class="w-100 h-100" type="file" id="jsonfilechooser"></div>
<div class="col-2"><button class="btn btn-primary w-100 h-100" id="uploadjson" type="button">Upload</button></div>
</div>
<div class="row">
<div class="col-3 h-100"><label class="col-form-label h-100">Voice Type</label></div>
<div class="col"><select class="h-100" id="voicetype">
<option value="Wavenet-A" selected="">Female 1</option>
<option value="Wavenet-D">Female 2</option>
<option value="Wavenet-B">Male 1</option>
<option value="Wavenet-C">Male 2</option>
<option value=""></option>
</select></div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="card card-setting">
<div class="card-body pad-accordion">
<h4 class="card-title">Content Generator</h4>
<hr>
<div class="row">
<div class="col-3"><label class="col-form-label h-100">Language to Generate</label></div>
<div class="col-2"><select class="h-100" id="languagetogenerate">
<option value="id-ID" selected="">Indonesia</option>
<option value="en-us">English</option>
<option value="ja-JP">Japanese</option>
<option value="zh-CN">Chinese</option>
<option value="ar-SA">Arabic</option>
</select></div>
</div>
<div class="row">
<div class="col-3"><label class="col-form-label h-100">Database Source</label></div>
<div class="col-2"><select class="h-100" id="databasesource">
<option value="VOICE_1" selected="">Voice 1</option>
<option value="VOICE_2">Voice 2</option>
<option value="VOICE_3">Voice 3</option>
</select></div>
</div>
<div class="row">
<div class="col-3"><label class="col-form-label h-100">Target As</label></div>
<div class="col-2"><select class="w-100 h-100" id="targetas">
<option value="VOICE_1">Voice 1</option>
<option value="VOICE_2" selected="">Voice 2</option>
<option value="VOICE_3">Voice 3</option>
</select></div>
<div class="col-3">
<div class="row"><label class="form-label fw-semibold">File Operation</label></div>
<div class="row">
<div class="form-check"><input class="form-check-input" type="radio" id="formCheck-1" name="fileoperation" value="skip" checked=""><label class="form-check-label" for="formCheck-1">Skip when exists</label></div>
</div>
<div class="row">
<div class="form-check"><input class="form-check-input" type="radio" id="formCheck-2" name="fileoperation" value="overwrite"><label class="form-check-label" for="formCheck-2">Overwrite</label></div>
</div>
</div>
<div class="col">
<div class="row"><label class="form-label fw-semibold">Database Operation</label></div>
<div class="row">
<div class="form-check"><input class="form-check-input" type="radio" id="formCheck-3" name="autoadd" value="add" checked=""><label class="form-check-label" for="formCheck-3">Auto Add when not exists</label></div>
</div>
<div class="row">
<div class="form-check"><input class="form-check-input" type="radio" id="formCheck-4" name="autoadd" value="skip"><label class="form-check-label" for="formCheck-4">Skip</label></div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="row"><button class="btn btn-primary" id="startstopgeneration" type="button">Start Generating</button></div>
<div class="row">
<div class="progress w-100 invisible" id="generateprogress">
<div class="progress-bar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%;">0%</div>
</div>
</div>
</div>
</div>
<div class="row">
<ul class="list-unstyled w-100 h-100" id="generatelogs"></ul>
</div>
</div>
</div>
</div>
</div>
<script src="assets/bootstrap/js/bootstrap.min.js"></script>
<script src="assets/js/bs-init.js"></script>
<script src="assets/js/tts.js"></script>
</body>
</html>

View File

@@ -181,6 +181,10 @@ class Somecodes {
} else cb.accept(-1,-1) } else cb.accept(-1,-1)
} }
fun GetFilename(path: String) : String {
return Path.of(path).fileName.toString()
}
fun ExtractFilesFromClassPath(resourcePath: String, outputDir: Path) { fun ExtractFilesFromClassPath(resourcePath: String, outputDir: Path) {
try { try {
val resource = Somecodes::class.java.getResource(resourcePath) val resource = Somecodes::class.java.getResource(resourcePath)

View File

@@ -1,21 +1,126 @@
package google package google
import codes.Somecodes
import com.google.cloud.texttospeech.v1.AudioConfig import com.google.cloud.texttospeech.v1.AudioConfig
import com.google.cloud.texttospeech.v1.AudioEncoding import com.google.cloud.texttospeech.v1.AudioEncoding
import com.google.cloud.texttospeech.v1.SynthesisInput import com.google.cloud.texttospeech.v1.SynthesisInput
import com.google.cloud.texttospeech.v1.TextToSpeechClient import com.google.cloud.texttospeech.v1.TextToSpeechClient
import com.google.cloud.texttospeech.v1.VoiceSelectionParams import com.google.cloud.texttospeech.v1.VoiceSelectionParams
import content.Category
import content.Language
import content.VoiceType
import database.Soundbank
import org.tinylog.Logger import org.tinylog.Logger
import java.nio.file.Files import java.nio.file.Files
import java.util.function.BiConsumer
import kotlin.io.path.Path import kotlin.io.path.Path
import db
import java.nio.file.Paths
import kotlin.io.path.absolutePathString
@Suppress("unused") @Suppress("unused")
class GoogleTTS(credentialJson: String = "c:/googlettsapi/gtc-cloud-aas-ae23c9552e1f.json") { class GoogleTTS(credentialJson: String = "c:/googlettsapi/gtc-cloud-aas-ae23c9552e1f.json") {
private var isGeneratingSoundbank = false
init{ init{
System.setProperty("GOOGLE_APPLICATION_CREDENTIALS", credentialJson) System.setProperty("GOOGLE_APPLICATION_CREDENTIALS", credentialJson)
} }
fun Speak(text: String, language : GoogleTTSLanguage, voicetype: GoogleTTSVoiceType, targetfilename: String){ /**
* Generate soundbank
* @param voicetype Voice type, default Wavenet-A
* @param language Language of the soundbank, default INDONESIA
* @param databasesource Database source, default VOICE_1
* @param targetas Target as, default VOICE_2
* @param file_operation File operation, options are overwrite or skip, default overwrite
* @param auto_add Auto add, options are add or skip, default add
*/
fun GenerateSoundbank(voicetype: String = "Wavenet-A", language: Language = Language.INDONESIA, databasesource: VoiceType = VoiceType.VOICE_1, targetas: VoiceType = VoiceType.VOICE_2, file_operation: fileoperation , auto_add: autoadd, progress: BiConsumer<Int, String>){
val source = db.soundDB.List
.filter { it.VoiceType==databasesource.name}
.filter { it.Language==language.name }
val target = db.soundDB.List
.filter { it.VoiceType==targetas.name}
.filter { it.Language==language.name }
if (source.isNotEmpty()){
isGeneratingSoundbank = true
source.forEachIndexed { index, s ->
if (!isGeneratingSoundbank){
Logger.info { "Soundbank generation stopped by user" }
progress.accept(index, "Stopped by user")
return@forEachIndexed
}
val targetfolder = Somecodes.SoundbankDirectory(language, targetas, Category.valueOf(s.Category))
val targetfile = Somecodes.GetFilename(s.Path)
val fullpath = targetfolder.resolve(targetfile)
var targetrow = target.find { it.Category == s.Category && it.TAG == s.TAG }
if (Files.exists(fullpath) && file_operation == fileoperation.SKIP){
// file exists, skip, not creating new file
Logger.info { "Skipping existing file : ${fullpath.absolutePathString()}" }
progress.accept(index , "Skipping existing file : ${fullpath.absolutePathString()}")
if (targetrow==null && auto_add == autoadd.ADD){
// file ada, tapi tidak ada di database target, add ke database
// add ke target database
targetrow = Soundbank(
index =0U,
Description = s.Description,
TAG = s.TAG,
Category = s.Category,
Language = s.Language,
VoiceType = targetas.name,
Path = fullpath.absolutePathString()
)
db.soundDB.Add(targetrow)
Logger.info { "Added to target database TAG ${s.TAG} Category ${s.Category}" }
progress.accept(index , "Added to target database TAG ${s.TAG} Category ${s.Category}")
}
} else {
// file not exists, or overwrite
if (targetrow!=null){
// ada
Speak(
text = s.Description,
language = GoogleTTSLanguage.fromLanguage(Language.valueOf(s.Language)),
voicetype = GoogleTTSVoiceType.valueOf(voicetype),
targetfilename = fullpath.absolutePathString()
){
success, message ->
if (success){
} else {
Logger.error { "Failed to generate sound for TAG ${s.TAG} Category ${s.Category}, message : $message" }
progress.accept(index , "Failed to generate sound for TAG ${s.TAG} Category ${s.Category}, message : $message")
}
}
} else {
// tidak ada
if (auto_add == autoadd.ADD){
// add ke target database
} else {
// skip
Logger.info { "Skipping TAG ${s.TAG} Category ${s.Category} as it does not exist in target database" }
progress.accept(index , "Skipping TAG ${s.TAG} Category ${s.Category} as it does not exist in target database")
}
}
}
}
isGeneratingSoundbank = false
}
}
/**
* Stop generate soundbank
*/
fun StopGenerate(){
isGeneratingSoundbank = false
}
fun Speak(text: String, language : GoogleTTSLanguage, voicetype: GoogleTTSVoiceType, targetfilename: String, event: BiConsumer<Boolean, String>? = null){
TextToSpeechClient.create().use { client -> TextToSpeechClient.create().use { client ->
try{ try{
val input = SynthesisInput.newBuilder().setText(text).build() val input = SynthesisInput.newBuilder().setText(text).build()
@@ -32,12 +137,12 @@ class GoogleTTS(credentialJson: String = "c:/googlettsapi/gtc-cloud-aas-ae23c955
val target = Path(targetfilename) val target = Path(targetfilename)
Files.write(target, bytes) Files.write(target, bytes)
Logger.info { "Speak success, file saved to : $targetfilename" } Logger.info { "Speak success, file saved to : $targetfilename" }
event?.accept(true, targetfilename)
} catch (e : Exception){ } catch (e : Exception){
Logger.error { "Speak failed, message : ${e.message}" } Logger.error { "Speak failed, message : ${e.message}" }
event?.accept(false, e.message ?: "Unknown error")
} }
} }
} }
} }

View File

@@ -1,10 +1,27 @@
package google package google
import content.Language
@Suppress("unused") @Suppress("unused")
enum class GoogleTTSLanguage(code: String) { enum class GoogleTTSLanguage(code: String) {
Indonesia("id-ID"), Indonesia("id-ID"),
English("en-US"), English("en-US"),
Japanese("ja-JP"), Japanese("ja-JP"),
Chinese("zh-CN"), Chinese("zh-CN"),
Arabic("ar-SA") Arabic("ar-SA");
companion object {
fun fromLanguage(lang: Language) : GoogleTTSLanguage {
return when(lang) {
Language.INDONESIA -> Indonesia
Language.ENGLISH -> English
Language.JAPANESE -> Japanese
Language.CHINESE -> Chinese
Language.ARABIC -> Arabic
else -> Indonesia
}
}
}
} }

7
src/google/autoadd.kt Normal file
View File

@@ -0,0 +1,7 @@
package google
@Suppress("unused")
enum class autoadd(name: String) {
ADD("add"),
SKIP("skip");
}

View File

@@ -0,0 +1,7 @@
package google
@Suppress("unused")
enum class fileoperation(name: String) {
OVERWRITE("overwrite"),
SKIP("skip");
}

View File

@@ -44,6 +44,7 @@ import java.time.LocalDateTime
import codes.configKeys import codes.configKeys
import config import config
import database.QueueTable import database.QueueTable
import google.GoogleTTS
import io.javalin.websocket.WsCloseStatus import io.javalin.websocket.WsCloseStatus
import org.tinylog.Logger import org.tinylog.Logger
import java.io.File import java.io.File
@@ -59,6 +60,7 @@ class WebApp(val listenPort: Int, var userlist: List<Pair<String, String>>, val
lateinit var semiauto: Javalin lateinit var semiauto: Javalin
val objectmapper = jacksonObjectMapper() val objectmapper = jacksonObjectMapper()
val WsContextMap = mutableMapOf<String, LiveListenData>() val WsContextMap = mutableMapOf<String, LiveListenData>()
val ttsjob = GoogleTTS()
private fun SendReply(context: WsMessageContext, command: String, value: String) { private fun SendReply(context: WsMessageContext, command: String, value: String) {
try { try {
@@ -201,6 +203,32 @@ class WebApp(val listenPort: Int, var userlist: List<Pair<String, String>>, val
SendReply(wsMessageContext, cmd.command, objectmapper.writeValueAsString(reply)) SendReply(wsMessageContext, cmd.command, objectmapper.writeValueAsString(reply))
} }
"start_generate_tts" ->{
val js : JsonNode = objectmapper.readTree(cmd.data)
SendReply(wsMessageContext, cmd.command, "ok")
val voicetype = js.get("voicetype")?.asText("Wavenet-A") ?: "Wavenet-A"
val languagecode = js.get("languagecode")?.asText("id-ID") ?: "id-ID"
val databasesource = js.get("databasesource")?.asText("VOICE_1") ?: "VOICE_1"
val targetas = js.get("targetas")?.asText("VOICE_2") ?: "VOICE_2"
val fileoperation = js.get("fileoperation")?.asText("overwrite") ?: "overwrite"
val autoadd = js.get("autoadd")?.asText("add") ?: "add"
Logger.info {"Starting TTS Soundbank Generation, VoiceType=$voicetype, Language"}
ttsjob.GenerateSoundbank(voicetype, languagecode, databasesource, targetas, fileoperation, autoadd){ progress, message ->
SendReply(wsMessageContext, "tts_generate_progress", objectmapper.writeValueAsString(
mapOf(
"progress" to progress,
"message" to message
)
))
}
}
"stop_generate_tts" ->{
SendReply(wsMessageContext, cmd.command, "ok")
ttsjob.StopGenerate()
}
else -> { else -> {
SendReply(wsMessageContext, cmd.command, "Unknown command") SendReply(wsMessageContext, cmd.command, "Unknown command")
} }