From 421ed661addd80919a48a075eee63835e60f2295 Mon Sep 17 00:00:00 2001 From: rdkartono Date: Fri, 21 Nov 2025 13:33:37 +0700 Subject: [PATCH] commit 21/11/2025 --- html/webpage/assets/js/overview.js | 49 +++--- src/Main.kt | 6 +- src/audio/Mp3Encoder.kt | 9 +- src/barix/BarixConnection.kt | 60 ++++--- .../TCP_Android_Command_Server.kt | 4 +- src/web/LiveListenData.kt | 6 + src/web/WebApp.kt | 164 ++++++++++-------- 7 files changed, 165 insertions(+), 133 deletions(-) create mode 100644 src/web/LiveListenData.kt diff --git a/html/webpage/assets/js/overview.js b/html/webpage/assets/js/overview.js index 83a9e68..35e0fd1 100644 --- a/html/webpage/assets/js/overview.js +++ b/html/webpage/assets/js/overview.js @@ -55,7 +55,6 @@ function UpdateStreamerCard(values) { const v = Number(value ?? 0); const pct = Math.max(0, Math.min(100, Math.round((v / max) * 100))); //if (index!==1) return; // only update index 1 for testing - //console.log(`setProgress: index=${index}, value=${v}, pct=${pct}`); $bar .attr('aria-valuenow', v) // semantic value .css('width', pct + '%') // visual width @@ -162,7 +161,6 @@ function fill_automaticqueuetablebody(vv) { if (!Array.isArray(vv) || vv.length === 0) return; vv.forEach(item => { // fill index and description columns using item properties - //console.log("fill_automaticqueuetablebody: item", item); $('#automaticqueuetable').append(` ${item.index} ${item.date_Time} @@ -218,7 +216,6 @@ function reloadAutomaticQueue(APIURL = "QueueTable/") { function RemovePagingQueueByIndex(index, APIURL = "QueuePaging/") { fetchAPI(APIURL + "DeleteByIndex/" + index, "DELETE", {}, null, (okdata) => { - console.log("RemovePagingQueueByIndex: okdata", okdata); reloadPagingQueue(APIURL); }, (errdata) => { console.log("RemovePagingQueueByIndex: errdata", errdata); @@ -227,7 +224,6 @@ function RemovePagingQueueByIndex(index, APIURL = "QueuePaging/") { function RemoveAutomaticQueueByIndex(index, APIURL = "QueueTable/") { fetchAPI(APIURL + "DeleteByIndex/" + index, "DELETE", {}, null, (okdata) => { - console.log("RemoveAutomaticQueueByIndex: okdata", okdata); reloadAutomaticQueue(APIURL); }, (errdata) => { console.log("RemoveAutomaticQueueByIndex: errdata", errdata); @@ -258,16 +254,16 @@ function GetListeningZones() { * @param {Function} cbFail callback function on failure */ function LiveAudioCommand(command, bz, cbOK = null, cbFail = null) { - function raise_cbOK(value=null){ + function raise_cbOK(value = null) { if (cbOK) cbOK(value); } - function raise_cbFail(value){ + function raise_cbFail(value) { if (cbFail) cbFail(value); } - if (command && command.length>0){ - if (bz && bz.length>0){ - if (command === 'Open' || command === 'Close'){ + if (command && command.length > 0) { + if (bz && bz.length > 0) { + if (command === 'Open' || command === 'Close') { let url = `/api/LiveAudio`; let payload = { method: 'POST', @@ -281,22 +277,19 @@ function LiveAudioCommand(command, bz, cbOK = null, cbFail = null) { }; fetch(url, payload) .then(response => { - console.log(`Fetch response for Live Audio Command ${command}:`, JSON.stringify(response)); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then(data => { - console.log(`Live Audio Command ${command} for Broadcast Zone: ${bz} executed on server.`, data); raise_cbOK(data); }) .catch(error => { - console.log(`Error executing Live Audio Command ${command} for Broadcast Zone: ${bz} on server. ${error}`); raise_cbFail(error); }); - } else raise_cbFail("LiveAudioCommand: Unknown command "+command); + } else raise_cbFail("LiveAudioCommand: Unknown command " + command); } else raise_cbFail("LiveAudioCommand: Broadcast Zone is empty"); } else raise_cbFail("LiveAudioCommand: command is empty"); } @@ -308,7 +301,6 @@ let streamws = null; let mediasource = null; $(document).ready(function () { - console.log("overview.js loaded"); GetListeningZones(); @@ -316,8 +308,10 @@ $(document).ready(function () { let bz = $("#listenzone").val(); let $icon = $(this).find('svg'); if ($icon.hasClass('fa-stop')) { - console.log("Stopping Live Audio for Broadcast Zone:", bz); - LiveAudioCommand('Close', bz, (okdata) =>{ + LiveAudioCommand('Close', bz, (okdata) => { + if (okdata.message && okdata.message.length > 0) { + console.log("Live Audio Session Closed:", okdata.message); + } $icon.toggleClass('fa-stop fa-play'); $("#listenzone").prop('disabled', false); if (streamws) { @@ -331,34 +325,37 @@ $(document).ready(function () { let audio = document.getElementById('listenaudio'); audio.src = ""; - }, (errdata) =>{ + }, (errdata) => { alert("Error stopping Live Audio: " + errdata); }); } else { - console.log("Starting Live Audio for Broadcast Zone:", bz); - LiveAudioCommand('Open', bz, (okdata) =>{ + LiveAudioCommand('Open', bz, (okdata) => { + if (okdata.message && okdata.message.length > 0) { + let uuid = okdata.message; + console.log("Live Audio Session UUID:", uuid); + } $icon.toggleClass('fa-stop fa-play'); $("#listenzone").prop('disabled', true); - streamws = new WebSocket(`ws://${window.location.host}/LiveAudio/ws`); + streamws = new WebSocket(`ws://${window.location.host}/api/LiveAudio/ws`); + streamws.binaryType = 'arraybuffer'; mediasource = new MediaSource(); let audio = document.getElementById('listenaudio'); audio.src = URL.createObjectURL(mediasource); mediasource.addEventListener('sourceopen', () => { - const sourceBuffer = mediasource.addSourceBuffer('audio/mpeg; codecs="mp3"'); - streamws.binaryType = 'arraybuffer'; + const sourceBuffer = mediasource.addSourceBuffer('audio/mpeg'); streamws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { const chunk = new Uint8Array(event.data); sourceBuffer.appendBuffer(chunk); - } + } }; }); - }, (errdata) =>{ + }, (errdata) => { alert("Error starting Live Audio: " + errdata); }); } - + }); $('#clearpagingqueue').off('click').on('click', function () { DoClear("QueuePaging/", "Paging Queue", (okdata) => { @@ -445,7 +442,6 @@ $(document).ready(function () { switch (cmd) { case "getPagingQueue": let pq = JSON.parse(data); - //console.log("getPagingQueue:", pq); window.PagingQueue = []; if (Array.isArray(pq) && pq.length > 0) { window.PagingQueue.push(...pq); @@ -454,7 +450,6 @@ $(document).ready(function () { break; case "getAASQueue": let aq = JSON.parse(data); - //console.log("getAASQueue:", aq); window.QueueTable = []; if (Array.isArray(aq) && aq.length > 0) { window.QueueTable.push(...aq); diff --git a/src/Main.kt b/src/Main.kt index aa61ad5..bf200f8 100644 --- a/src/Main.kt +++ b/src/Main.kt @@ -231,7 +231,8 @@ fun main() { db.Add_Log("AAS"," Application started") // shutdown hook - Runtime.getRuntime().addShutdownHook(Thread { + Runtime.getRuntime().addShutdownHook(Thread ({ + db.Add_Log("AAS"," Application stopping") Logger.info { "Shutdown hook called, stopping services..." } barixserver.StopTcpCommand() @@ -240,11 +241,12 @@ fun main() { web.Stop() udpreceiver.Stop() tcpreceiver.Stop() + StreamerOutputs.values.forEach { it.close() } audioPlayer.Close() db.close() Logger.info { "All services stopped, exiting application." } ProviderRegistry.getLoggingProvider().shutdown() - }) + },"ShutdownHook") ) } diff --git a/src/audio/Mp3Encoder.kt b/src/audio/Mp3Encoder.kt index d55be5c..8a50a6b 100644 --- a/src/audio/Mp3Encoder.kt +++ b/src/audio/Mp3Encoder.kt @@ -48,9 +48,10 @@ class Mp3Encoder(val samplingrate: Int=44100, val channels: Int=1) { push_handle = bass.BASS_StreamCreate(samplingrate, channels, Bass.BASS_STREAM_DECODE, Pointer(-1), null) if (push_handle!=0){ Logger.info{"MP3 Encoder initialized with sampling rate $samplingrate Hz and $channels channel(s)" } - //val options = "lame -b 128 --cbr --write-xing 0 --nohist --noid3v2 - -" - val flag = BassEnc.BASS_ENCODE_AUTOFREE or BassEnc.BASS_ENCODE_NOHEAD - mp3_handle = bassencmp3.BASS_Encode_MP3_Start(push_handle, null, flag,proc, null) + val options = "lame -b 128 --cbr --write-xing 0 --nohist --noid3v2 - -" + //val flag = BassEnc.BASS_ENCODE_AUTOFREE or BassEnc.BASS_ENCODE_NOHEAD + val flag = BassEnc.BASS_ENCODE_AUTOFREE + mp3_handle = bassencmp3.BASS_Encode_MP3_Start(push_handle, options, flag,proc, null) if (mp3_handle!=0){ callback = cb @@ -100,7 +101,6 @@ class Mp3Encoder(val samplingrate: Int=44100, val channels: Int=1) { Logger.error{"Failed to push data to MP3 Encoder. BASS error code: $err" } return 0 } - //println("MP3 Encoder: Pushed $written bytes of PCM data.") return written } @@ -122,6 +122,5 @@ class Mp3Encoder(val samplingrate: Int=44100, val channels: Int=1) { // auto close by BASS_ENCODE_AUTOFREE mp3_handle = 0 callback = null - println("MP3 Encoder: Stopped.") } } \ No newline at end of file diff --git a/src/barix/BarixConnection.kt b/src/barix/BarixConnection.kt index 131f7f5..24599e6 100644 --- a/src/barix/BarixConnection.kt +++ b/src/barix/BarixConnection.kt @@ -16,7 +16,7 @@ import java.nio.ByteBuffer import java.util.function.Consumer @Suppress("unused") -class BarixConnection(val index: UInt, var channel: String, val ipaddress: String, val port: Int = 5002) { +class BarixConnection(val index: UInt, var channel: String, val ipaddress: String, val port: Int = 5002) : AutoCloseable { private var _bR: Int = 0 private var _sd: Int = 0 private var _vu: Int = 0 @@ -26,6 +26,13 @@ class BarixConnection(val index: UInt, var channel: String, val ipaddress: Strin private var _tcp: Socket? = null private val mp3encoder = Mp3Encoder() private val mp3Consumer = mutableMapOf>() + private val udp = DatagramSocket() + + init { + mp3encoder.Start { data -> + mp3Consumer.values.forEach { it.accept(data) } + } + } fun Add_Mp3_Consumer(key: String, cb: Consumer) { mp3Consumer[key] = cb @@ -118,33 +125,26 @@ class BarixConnection(val index: UInt, var channel: String, val ipaddress: Strin fun SendData(data: ByteArray, cbOK: Consumer, cbFail: Consumer) { if (data.isNotEmpty()) { CoroutineScope(Dispatchers.IO).launch { - DatagramSocket().use{ udp -> - val bb = ByteBuffer.wrap(data) - if (!mp3encoder.isStarted()) { - mp3encoder.Start { data -> - mp3Consumer.values.forEach { it.accept(data) } + val bb = ByteBuffer.wrap(data) + + while(bb.hasRemaining()){ + try { + val chunk = ByteArray(if (bb.remaining() > maxUDPsize) maxUDPsize else bb.remaining()) + bb.get(chunk) + while(bufferRemain maxUDPsize) maxUDPsize else bb.remaining()) - bb.get(chunk) - while(bufferRemain>, val lateinit var app: Javalin lateinit var semiauto: Javalin val objectmapper = jacksonObjectMapper() - val WsContextMap = mutableMapOf() + val WsContextMap = mutableMapOf() private fun SendReply(context: WsMessageContext, command: String, value: String) { try { @@ -247,79 +248,100 @@ class WebApp(val listenPort: Int, val userlist: List>, val path("api") { //TODO https://stackoverflow.com/questions/70002015/streaming-into-audio-element path("LiveAudio") { - post{ - val json : JsonNode = objectmapper.readTree(it.body()) + post{ ctx-> + val json : JsonNode = objectmapper.readTree(ctx.body()) val broadcastzone = json.get("broadcastzone")?.asText("") ?: "" val command = json.get("command")?.asText("") ?: "" - println("LiveAudio command=$command for zone $broadcastzone from ${it.host()}" ) + if (command == "Open" || command == "Close"){ + if (broadcastzone.isNotEmpty()){ + val bc = Get_Barix_Connection_by_ZoneName(broadcastzone) + if (bc!=null){ + val key = ctx.cookie("client-stream-id") + if (command == "Open"){ + // open command + if (key!=null && key.isNotEmpty()){ + // ada connection sebelumnya, kemungkinan reconnect + val prev = WsContextMap[key] + if (prev!=null){ + prev.bc?.Remove_Mp3_Consumer(key) + prev.ws?.closeSession(WsCloseStatus.NORMAL_CLOSURE, "Reopen Live Audio Stream") + } + WsContextMap.remove(key) + ctx.cookie("client-stream-id", "") + } + val newkey = UUID.randomUUID().toString() + .also { ctx.cookie("client-stream-id", it) } + WsContextMap[newkey] = LiveListenData(newkey, bc, null) + ResultMessageString(ctx, 200, newkey) + + + } else { + // close command + if (key!=null && key.isNotEmpty()){ + // close connection + val prev = WsContextMap[key] + if (prev!=null){ + prev.bc?.Remove_Mp3_Consumer(key) + prev.ws?.closeSession(WsCloseStatus.NORMAL_CLOSURE, "Close Live Audio Stream") + } + WsContextMap.remove(key) + ctx.cookie("client-stream-id", "") + ResultMessageString(ctx, 200, "OK") + } else ResultMessageString(ctx, 400, "No client-id cookie found") + } + } else ResultMessageString(ctx, 400, "Broadcastzone not found") + } else ResultMessageString(ctx, 400, "Invalid broadcastzone") + } else ResultMessageString(ctx, 400, "Invalid command") } - ws("/ws/{uuid}"){ws -> - ws.onConnect { - ctx -> - val uuid = ctx.pathParam("uuid") - val cookieresult = ctx.cookie("client-stream-id") - println("Ws connected with uuid=$uuid, cookie=$cookieresult") - WsContextMap[uuid] = ctx - } - - ws.onClose { - ctx -> - val uuid = ctx.pathParam("uuid") - println("Ws close on uuid=$uuid") - WsContextMap.remove(uuid) - } - ws.onError { - ctx -> - val uuid = ctx.pathParam("uuid") - println("Ws error on uuid=$uuid") - WsContextMap.remove(uuid) - } - } - - get("Open/{broadcastzone}") { ctx -> - val param = ctx.pathParam("broadcastzone") - println("LiveAudio Open for zone $param from ${ctx.host()}" ) - if (param.isNotEmpty()) { - val bc = Get_Barix_Connection_by_ZoneName(param) - if (bc != null) { - - val key = ctx.cookie("client-stream-id") ?: UUID.randomUUID().toString() - .also { ctx.cookie("client-stream-id", it) } - - bc.Add_Mp3_Consumer(key){ mp3data -> - WsContextMap[key]?.send(mp3data) - println("Send ${mp3data.size} to $key") + ws("ws"){ wscontext -> + wscontext.onConnect { + val key = it.cookie("client-stream-id") + if (key!=null && key.isNotEmpty()){ + val lld = WsContextMap[key] + if (lld!=null){ + it.enableAutomaticPings() + lld.ws = it + lld.bc?.Add_Mp3_Consumer(key){ mp3data-> + try { + if (it.session.isOpen){ + it.send(ByteBuffer.wrap(mp3data)) + } + } catch (e: Exception){ + Logger.error {"Error sending LiveAudio mp3 data for key $key, Message: ${e.message}"} + } + } + } else { + it.closeSession(WsCloseStatus.POLICY_VIOLATION, "WsContextMap key not found") + Logger.info{"LiveAudio WebSocket connection rejected, WsContextMap key not found"} } - - ResultMessageString(ctx, 200, key) - } else ctx.status(400) - .result(objectmapper.writeValueAsString(resultMessage("Broadcastzone not found"))) - } else ctx.status(400) - .result(objectmapper.writeValueAsString(resultMessage("Invalid broadcastzone"))) - - } - get("Close/{broadcastzone}") { ctx -> - val param = ctx.pathParam("broadcastzone") - println("LiveAudio Close for zone $param") - val key = ctx.cookie("client-stream-id") - if (key != null && key.isNotEmpty()) { - if (param.isNotEmpty()) { - val bc = Get_Barix_Connection_by_ZoneName(param) - if (bc != null) { - bc.Remove_Mp3_Consumer(key) - WsContextMap.remove(key) - ResultMessageString(ctx, 200, "OK") - println("LiveAudio Close for zone $param SUCCESS") - } else ctx.status(400) - .result(objectmapper.writeValueAsString(resultMessage("Broadcastzone not found"))) - } else ctx.status(400) - .result(objectmapper.writeValueAsString(resultMessage("Invalid broadcastzone"))) - } else { - ctx.status(400) - .result(objectmapper.writeValueAsString(resultMessage("No client-id cookie found"))) + } else { + it.closeSession(WsCloseStatus.POLICY_VIOLATION, "Invalid client-stream-id") + Logger.info{"LiveAudio WebSocket connection rejected, invalid client-stream-id"} + } } + wscontext.onClose { + val key = it.cookie("client-stream-id") + if (key!=null && key.isNotEmpty()){ + val lld = WsContextMap[key] + lld?.bc?.Remove_Mp3_Consumer(key) + lld?.ws?.closeSession() + lld?.bc = null + lld?.ws= null + WsContextMap.remove(key) + Logger.info{"LiveAudio WebSocket closed for key $key"} + + } + + } + wscontext.onError { + val key = it.cookie("client-stream-id") + val msg = it.error()?.message ?: "" + Logger.info{"LiveAudio WebSocket error for key $key, Message: $msg"} + } + } + } path("VoiceType") { get { @@ -1876,7 +1898,6 @@ class WebApp(val listenPort: Int, val userlist: List>, val val language = it.pathParam("language") val voice = it.pathParam("voice") val category = it.pathParam("category") - //println("ListSoundbank called with language=$language, voice=$voice, category=$category") if (ValidString(language) && Language.entries.any { lang -> lang.name == language }) { if (ValidString(voice) && VoiceType.entries.any { vtype -> vtype.name == voice }) { if (ValidString(category) && Category.entries.any { cat -> cat.name == category }) { @@ -1889,7 +1910,6 @@ class WebApp(val listenPort: Int, val userlist: List>, val // just the filename, without path mm.substring(mm.lastIndexOf(File.separator) + 1) } - //println("ListSoundbank result: $result") it.result(objectmapper.writeValueAsString(result)) } else { it.status(400) @@ -1911,7 +1931,6 @@ class WebApp(val listenPort: Int, val userlist: List>, val val category = it.pathParam("category") val uploaded = it.uploadedFiles() - println("UploadSoundbank called with language=$language, voice=$voice, category=$category, uploaded files count=${uploaded.size}") if (ValidString(language) && Language.entries.any { lang -> lang.name == language }) { if (ValidString(voice) && VoiceType.entries.any { vtype -> vtype.name == voice }) { if (ValidString(category) && Category.entries.any { cat -> cat.name == category }) { @@ -2218,7 +2237,6 @@ class WebApp(val listenPort: Int, val userlist: List>, val if (db.queuetableDB.Add(qt)) { db.queuetableDB.Resort() Logger.info { "SemiAutoWeb added to queue table: $qt" } - //println("SemiAuto added to queue table: ${objectmapper.writeValueAsString(qt)}") ctx.result(objectmapper.writeValueAsString(resultMessage("OK"))) } else ResultMessageString(ctx, 500, "Failed to add to queue table") } else ResultMessageString(ctx, 400, "Broadcast zones cannot be empty") @@ -2232,10 +2250,8 @@ class WebApp(val listenPort: Int, val userlist: List>, val get("/{datelog}") { ctx -> val datelog = ctx.pathParam("datelog") if (ValidDate(datelog)) { - println("SemiAuto Get Log for date $datelog") db.GetLogForHtml(datelog) { loghtml -> val resultstring = objectmapper.writeValueAsString(loghtml) - println("Log HTML for date $datelog: $resultstring") ctx.result(resultstring) }