diff --git a/.gitignore b/.gitignore index 3ddbf4c..882b89c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ ### IntelliJ IDEA ### out/ +audiofile/ !**/src/main/**/out/ !**/src/test/**/out/ diff --git a/EWS_POC.iml b/EWS_POC.iml index 0896530..1fbf6a0 100644 --- a/EWS_POC.iml +++ b/EWS_POC.iml @@ -9,6 +9,7 @@ + diff --git a/html/assets/js/pocreceiver.js b/html/assets/js/pocreceiver.js index d1915e5..ec4ceb2 100644 --- a/html/assets/js/pocreceiver.js +++ b/html/assets/js/pocreceiver.js @@ -1,20 +1,20 @@ $(document).ready(function() { // Your code here - console.log('pocreceiver.js is ready!'); + //console.log('pocreceiver.js is ready!'); const path = window.location.pathname; const ws = new WebSocket('ws://' + window.location.host + path + '/ws'); ws.onopen = function() { - console.log('WebSocket connection opened'); + //console.log('WebSocket connection opened'); $('#indicatorDisconnected').addClass('visually-hidden'); $('#indicatorConnected').removeClass('visually-hidden'); setInterval(function() { sendCommand({ command: "getZelloStatus" }); - }, 5000); + }, 1000); }; ws.onmessage = function(event) { - console.log('WebSocket message received:', event.data); + //console.log('WebSocket message received:', event.data); let msg = {}; try { msg = JSON.parse(event.data); @@ -25,13 +25,13 @@ $(document).ready(function() { } if (msg.reply === "getZelloStatus" && msg.data !== undefined && msg.data.length > 0) { const zelloData = msg.data; - console.log('Zello Status Data:', zelloData); - $('#zelloStatus').text(zelloData.status); + //console.log('Zello Status Data:', zelloData); + $('#zelloStatus').text(zelloData); } }; ws.onclose = function() { - console.log('WebSocket connection closed'); + //console.log('WebSocket connection closed'); $('#indicatorDisconnected').removeClass('visually-hidden'); $('#indicatorConnected').addClass('visually-hidden'); }; diff --git a/html/pocreceiver.html b/html/pocreceiver.html index c063d53..7d65085 100644 --- a/html/pocreceiver.html +++ b/html/pocreceiver.html @@ -35,7 +35,7 @@

Zello Status

-

Paragraph

+

Paragraph

diff --git a/src/Main.kt b/src/Main.kt index 0b29c27..ebcc15e 100644 --- a/src/Main.kt +++ b/src/Main.kt @@ -10,12 +10,14 @@ import web.webApp import zello.ZelloClient import zello.ZelloEvent import javafx.util.Pair +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.slf4j.LoggerFactory import somecodes.Codes import web.WsCommand import java.util.function.BiFunction -import kotlin.io.path.isRegularFile -import kotlin.io.path.pathString //TIP To Run code, press or // click the icon in the gutter. @@ -25,23 +27,140 @@ fun main() { val cfg = configFile() cfg.Load() - val au = AudioUtility() var audioID = 0 val preferedAudioDevice = "Speakers" - au.DetectPlaybackDevices().forEach { pair -> - println("Device ID: ${pair.first}, Name: ${pair.second}") + + AudioUtility.DetectPlaybackDevices().forEach { pair -> + logger.info("Device ID: ${pair.first}, Name: ${pair.second}") if (pair.second.contains(preferedAudioDevice)) { audioID = pair.first } } if (audioID!=0){ - val initsuccess = au.InitDevice(audioID,44100) - println("Audio Device $audioID initialized: $initsuccess") + val initsuccess = AudioUtility.InitDevice(audioID,44100) + logger.info("Audio Device $audioID initialized: $initsuccess") } + // for Zello Client val o = OpusStreamReceiver(audioID) + // for AudioFilePlayer var afp: AudioFilePlayer? = null + /** + * Creates a ZelloClient instance based on the configuration. + */ + fun CreateZelloFromConfig(): ZelloClient { + return when (cfg.ZelloServer) { + "work" -> { + ZelloClient.fromZelloWork( + cfg.ZelloUsername ?: "", + cfg.ZelloPassword ?: "", + cfg.ZelloChannel ?: "", + cfg.ZelloWorkNetworkName ?: "" + ) + } + "enterprise" -> { + ZelloClient.fromZelloEnterpriseServer( + cfg.ZelloUsername ?: "", + cfg.ZelloPassword ?: "", + cfg.ZelloChannel ?: "", + cfg.ZelloEnterpriseServerDomain ?: "" + ) + } + else -> { + ZelloClient.fromConsumerZello(cfg.ZelloUsername ?: "", cfg.ZelloPassword ?: "", cfg.ZelloChannel ?: "") + } + } + } + + // Create Zello client from configuration and start it + var z = CreateZelloFromConfig() + + // Create ZelloEvent implementation to handle events + val z_event = object : ZelloEvent{ + override fun onChannelStatus( + channel: String, + status: String, + userOnline: Int, + error: String?, + errorType: String? + ) { + logger.info("Channel Status: $channel is $status with $userOnline users online.") + if (ValidString(error) && ValidString(errorType)) { + logger.info("Error: $error, Type: $errorType") + } + } + + override fun onAudioData(streamID: Int, from: String, For: String, channel: String, data: ByteArray) { + logger.info("Audio Data received from $from for $For on channel $channel with streamID $streamID ") + } + + override fun onThumbnailImage(imageID: Int, from: String, For: String, channel: String, data: ByteArray, timestamp: Long) { + logger.info("Thumbnail Image received from $from for $For on channel $channel with imageID $imageID at timestamp $timestamp") + } + + override fun onFullImage(imageID: Int, from: String, For: String, channel: String, data: ByteArray, timestamp: Long) { + logger.info("Full Image received from $from for $For on channel $channel with imageID $imageID at timestamp $timestamp") + } + + override fun onTextMessage(messageID: Int, from: String, For: String, channel: String, text: String, timestamp: Long) { + logger.info("Text Message received from $from for $For on channel $channel with messageID $messageID at timestamp $timestamp. Text: $text") + } + + override fun onLocation(messageID: Int, from: String, For: String, channel: String, latitude: Double, longitude: Double, address: String, accuracy: Double, timestamp: Long) { + logger.info("Location received from $from for $For on channel $channel with messageID $messageID at timestamp $timestamp. Location($latitude,$longitude), Address:$address, Accuracy:$accuracy") + } + + override fun onConnected() { + logger.info("Connected to Zello server.") + } + + override fun onDisconnected(reason: String) { + logger.info("Disconnected from Zello Server, reason: $reason") + logger.info("Reconnecting after 10 seconds...") + z.Stop() + val e = this + CoroutineScope(Dispatchers.Default).launch { + + delay(10000) // Wait for 10 seconds before trying to reconnect + z = CreateZelloFromConfig() + z.Start(e) + } + } + + override fun onError(errorMessage: String) { + logger.info("Error occurred in Zello client: $errorMessage") + } + + override fun onStartStreaming(from: String, For: String, channel: String) { + // stop any previous playback + afp?.Stop() + afp = null + + if (o.Start()){ + logger.info("Opus Receiver ready for streaming from $from for $For on channel $channel") + } else { + logger.info("Failed to start Opus Receiver for streaming from $from for $For on channel $channel") + } + } + + override fun onStopStreaming(from: String, For: String, channel: String) { + o.Stop() + logger.info("Opus Receiver stopped streaming from $from for $For on channel $channel") + } + + override fun onStreamingData( + from: String, + For: String, + channel: String, + data: ByteArray + ) { + if (o.isPlaying) o.PushData(data) + } + } + z.Start(z_event) + + // Start the web application with WebSocket support val w = webApp("0.0.0.0",3030, BiFunction { source: String, cmd: WsCommand -> when (source) { @@ -70,14 +189,39 @@ fun main() { "setZelloConfig" -> { try{ val xx = objectMapper.readValue(cmd.data, object: TypeReference>() {}) - cfg.ZelloUsername = xx["ZelloUsername"] - cfg.ZelloPassword = xx["ZelloPassword"] - cfg.ZelloChannel = xx["ZelloChannel"] - cfg.ZelloServer = xx["ZelloServer"] - cfg.ZelloWorkNetworkName = xx["ZelloWorkNetworkName"] - cfg.ZelloEnterpriseServerDomain = xx["ZelloEnterpriseServerDomain"] - cfg.Save() - WsReply(cmd.command,"success") + var changed = false + if (cfg.ZelloUsername != xx["ZelloUsername"]) { + cfg.ZelloUsername = xx["ZelloUsername"] ?: "" + changed = true + } + if (cfg.ZelloPassword != xx["ZelloPassword"]) { + cfg.ZelloPassword = xx["ZelloPassword"] ?: "" + changed = true + } + if (cfg.ZelloChannel != xx["ZelloChannel"]) { + cfg.ZelloChannel = xx["ZelloChannel"] ?: "" + changed = true + } + if (cfg.ZelloServer != xx["ZelloServer"]) { + cfg.ZelloServer = xx["ZelloServer"] ?: "" + changed = true + } + if (cfg.ZelloWorkNetworkName != xx["ZelloWorkNetworkName"]) { + cfg.ZelloWorkNetworkName = xx["ZelloWorkNetworkName"] ?: "" + changed = true + } + if (cfg.ZelloEnterpriseServerDomain != xx["ZelloEnterpriseServerDomain"]) { + cfg.ZelloEnterpriseServerDomain = xx["ZelloEnterpriseServerDomain"] ?: "" + changed = true + } + if (changed){ + cfg.Save() + z.Stop() + z = CreateZelloFromConfig() + z.Start(z_event) + + WsReply(cmd.command,"success") + } else WsReply(cmd.command,"No changes made") } catch (e: Exception){ WsReply(cmd.command,"failed: ${e.message}") } @@ -87,16 +231,43 @@ fun main() { try{ val xx = objectMapper.readValue(cmd.data, object : TypeReference>() {}) - cfg.M1 = xx["M1"] - cfg.M2 = xx["M2"] - cfg.M3 = xx["M3"] - cfg.M4 = xx["M4"] - cfg.M5 = xx["M5"] - cfg.M6 = xx["M6"] - cfg.M7 = xx["M7"] - cfg.M8 = xx["M8"] - cfg.Save() - WsReply(cmd.command,"success") + var changed = false + if (cfg.M1 != xx["M1"]) { + cfg.M1 = xx["M1"] ?: "" + changed = true + } + if (cfg.M2 != xx["M2"]) { + cfg.M2 = xx["M2"] ?: "" + changed = true + } + if (cfg.M3 != xx["M3"]) { + cfg.M3 = xx["M3"] ?: "" + changed = true + } + if (cfg.M4 != xx["M4"]) { + cfg.M4 = xx["M4"] ?: "" + changed = true + } + if (cfg.M5 != xx["M5"]) { + cfg.M5 = xx["M5"] ?: "" + changed = true + } + if (cfg.M6 != xx["M6"]) { + cfg.M6 = xx["M6"] ?: "" + changed = true + } + if (cfg.M7 != xx["M7"]) { + cfg.M7 = xx["M7"] ?: "" + changed = true + } + if (cfg.M8 != xx["M8"]) { + cfg.M8 = xx["M8"] ?: "" + changed = true + } + if (changed){ + cfg.Save() + WsReply(cmd.command,"success") + } else WsReply(cmd.command,"No changes made") } catch (e: Exception){ WsReply(cmd.command,"failed: ${e.message}") } @@ -121,12 +292,18 @@ fun main() { } "getPlaybackStatus" ->{ if (afp!=null && true==afp?.isPlaying){ - WsReply(cmd.command, "Playing: ${afp?.ShortFileName()}") + WsReply(cmd.command, "Playing: ${afp?.filename}, Duration: ${afp?.duration?.toInt()}, Elapsed: ${afp?.elapsed?.toInt()} seconds") } else { WsReply(cmd.command, "Idle") } } "playMessage" ->{ + + afp?.Stop() + afp = null + // stop Opus Receiver if it is running + o.Stop() + val filename = when(cmd.data){ "M1" -> cfg.M1 "M2" -> cfg.M2 @@ -141,24 +318,21 @@ fun main() { } } if (filename!=null){ - val completefilename = Codes.audioFilePath.resolve(filename) - if (completefilename.isRegularFile()){ - try{ - - val player= AudioFilePlayer(audioID, completefilename.pathString) - player.Play { _ -> afp = null} - afp = player - WsReply(cmd.command,"success") - - } catch (e: Exception){ - WsReply(cmd.command, "failed: ${e.message}") - } - } else WsReply(cmd.command,"File Not Found : $filename") - } else WsReply(cmd.command,"Invalid message name: ${cmd.data}") - - - + try{ + afp= AudioFilePlayer(audioID, filename) + afp?.Play { _ -> afp = null} + WsReply(cmd.command,"success") + } catch (e: Exception){ + afp?.Stop() + afp = null + WsReply(cmd.command, "failed: ${e.message}") + } + } else { + afp?.Stop() + afp = null + WsReply(cmd.command,"Invalid message name: ${cmd.data}") + } } "stopMessage" ->{ @@ -169,6 +343,16 @@ fun main() { else -> WsReply(cmd.command,"Invalid command: ${cmd.command}") } "pocreceiver" -> when(cmd.command){ + "getZelloStatus" -> { + var status = "Disconnected" + if (z.currentChannel?.isNotBlank() == true){ + status = "Channel: ${z.currentChannel}, Online: ${z.isOnline}, Username: ${z.username}" + if (z.isReceivingStreaming){ + status += ", Streaming From: ${z.receivingFrom}, Bytes Received: ${Codes.SizeToString(z.bytesReceived)}" + } + } + WsReply(cmd.command, status) + } else -> WsReply(cmd.command,"Invalid command: ${cmd.command}") } else -> WsReply(cmd.command,"Invalid source: $source") @@ -177,76 +361,11 @@ fun main() { } , Pair("admin","admin1234")) w.Start() - val z = ZelloClient.fromConsumerZello("gtcdevice01","GtcDev2025") - z.Start(object : ZelloEvent { - override fun onChannelStatus( - channel: String, - status: String, - userOnline: Int, - error: String?, - errorType: String? - ) { - println("Channel Status: $channel is $status with $userOnline users online.") - if (ValidString(error) && ValidString(errorType)) { - println("Error: $error, Type: $errorType") - } - } - override fun onAudioData(streamID: Int, from: String, For: String, channel: String, data: ByteArray) { - println("Audio Data received from $from for $For on channel $channel with streamID $streamID ") - } - override fun onThumbnailImage(imageID: Int, from: String, For: String, channel: String, data: ByteArray, timestamp: Long) { - println("Thumbnail Image received from $from for $For on channel $channel with imageID $imageID at timestamp $timestamp") - } - - override fun onFullImage(imageID: Int, from: String, For: String, channel: String, data: ByteArray, timestamp: Long) { - println("Full Image received from $from for $For on channel $channel with imageID $imageID at timestamp $timestamp") - } - - override fun onTextMessage(messageID: Int, from: String, For: String, channel: String, text: String, timestamp: Long) { - println("Text Message received from $from for $For on channel $channel with messageID $messageID at timestamp $timestamp. Text: $text") - } - - override fun onLocation(messageID: Int, from: String, For: String, channel: String, latitude: Double, longitude: Double, address: String, accuracy: Double, timestamp: Long) { - println("Location received from $from for $For on channel $channel with messageID $messageID at timestamp $timestamp. Location($latitude,$longitude), Address:$address, Accuracy:$accuracy") - } - - override fun onConnected() { - println("Connected to Zello server.") - } - - override fun onDisconnected() { - println("Disconnected from Zello server.") - } - - override fun onError(errorMessage: String) { - println("Error occurred in Zello client: $errorMessage") - } - - override fun onStartStreaming(from: String, For: String, channel: String) { - if (o.Start()){ - println("Opus Receiver ready for streaming from $from for $For on channel $channel") - } else { - println("Failed to start Opus Receiver for streaming from $from for $For on channel $channel") - } - } - - override fun onStopStreaming(from: String, For: String, channel: String) { - o.Stop() - println("Opus Receiver stopped streaming from $from for $For on channel $channel") - } - - override fun onStreamingData( - from: String, - For: String, - channel: String, - data: ByteArray - ) { - if (o.isPlaying) o.PushData(data) - } - }) } + + diff --git a/src/audio/AudioFilePlayer.kt b/src/audio/AudioFilePlayer.kt index 7de5654..5883465 100644 --- a/src/audio/AudioFilePlayer.kt +++ b/src/audio/AudioFilePlayer.kt @@ -4,77 +4,67 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import somecodes.Codes import java.util.function.Consumer -import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.exists /** * Audio Player for playing audio files. * Supported extensions : .wav, .mp3 */ @Suppress("unused") -class AudioFilePlayer(deviceID: Int, filename: String, device_samplingrate: Int = 48000) { - val bass: Bass = Bass.Instance - var filehandle = 0 +class AudioFilePlayer(deviceID: Int, val filename: String, device_samplingrate: Int = 48000) { + private val bass: Bass = Bass.Instance + private var filehandle = 0 var isPlaying = false - private val filepath = Path(filename) + val fileSize: Long + val duration: Double + var elapsed: Double = 0.0 init{ - if (bass.BASS_SetDevice(deviceID)){ - filehandle = bass.BASS_StreamCreateFile(false, filename, 0, 0, 0) - if (filehandle == 0) { - throw Exception("Failed to create stream for file $filename: ${bass.BASS_ErrorGetCode()}") - } - } else throw Exception("Failed to set device $deviceID") + val fullpath = Codes.audioFilePath.resolve(filename) + if (fullpath.exists()){ + if (AudioUtility.InitDevice(deviceID, device_samplingrate)) { + filehandle = bass.BASS_StreamCreateFile(false, fullpath.absolutePathString(), 0, 0, 0) + if (filehandle!=0){ + fileSize = bass.BASS_ChannelGetLength(filehandle, Bass.BASS_POS_BYTE) + duration = bass.BASS_ChannelBytes2Seconds(filehandle, fileSize) + } else throw Exception("Failed to create stream for file $filename: ${bass.BASS_ErrorGetCode()}") + + } else throw Exception("Error initializing device $deviceID with sampling rate $device_samplingrate, code ${bass.BASS_ErrorGetCode()}") + } else throw Exception("File $filename does not exists") + } fun Stop(){ if (filehandle!=0){ - bass.BASS_ChannelStop(filehandle) - bass.BASS_StreamFree(filehandle) + bass.BASS_ChannelFree(filehandle) filehandle = 0 } - } - - fun ShortFileName() : String { - return filepath.fileName.toString() + isPlaying = false + AudioUtility.Free() } fun Play(finished: Consumer ) : Boolean{ if (bass.BASS_ChannelPlay(filehandle, false)){ - + elapsed = 0.0 CoroutineScope(Dispatchers.Default).launch { isPlaying = true while(true){ - delay(1000) + delay(50) if (bass.BASS_ChannelIsActive(filehandle)!= Bass.BASS_ACTIVE_PLAYING){ // finished playing break } + elapsed = bass.BASS_ChannelBytes2Seconds(filehandle, bass.BASS_ChannelGetPosition(filehandle, Bass.BASS_POS_BYTE)) } isPlaying = false - bass.BASS_StreamFree(filehandle) + bass.BASS_ChannelFree(filehandle) filehandle = 0 finished.accept(true) } - // Revisi 06/08/2025 Ganti thread dengan Coroutine -// val thread = Thread{ -// isPlaying = true -// while(true){ -// Thread.sleep(1000) -// -// if (bass.BASS_ChannelIsActive(filehandle)!= Bass.BASS_ACTIVE_PLAYING){ -// // finished playing -// break -// } -// } -// isPlaying = false -// bass.BASS_StreamFree(filehandle) -// filehandle = 0 -// finished.accept(true) -// } -// thread.name = "AudioFilePlayer $filename" -// thread.isDaemon = true -// thread.start() + return true } return false diff --git a/src/audio/AudioUtility.kt b/src/audio/AudioUtility.kt index af789e8..0a7a194 100644 --- a/src/audio/AudioUtility.kt +++ b/src/audio/AudioUtility.kt @@ -3,39 +3,61 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory class AudioUtility { - private val bass = Bass.Instance + private val logger : Logger = LoggerFactory.getLogger(AudioUtility::class.java) + + + companion object{ + private val bass = Bass.Instance + + fun DetectPlaybackDevices() : List> { + val result = ArrayList>() + for(i in 0..10){ + val dev = Bass.BASS_DEVICEINFO() + if (bass.BASS_GetDeviceInfo(i, dev)){ + if (dev.flags and Bass.BASS_DEVICE_ENABLED != 0){ + result.add(Pair(i, dev.name)) + } + } + } + return result + } + + fun InitDevice(deviceID: Int, device_samplingrate: Int = 48000) : Boolean { + val dev = Bass.BASS_DEVICEINFO() + if (bass.BASS_GetDeviceInfo(deviceID, dev)) { + if (dev.flags and Bass.BASS_DEVICE_ENABLED > 0){ + if (dev.flags and Bass.BASS_DEVICE_INIT > 0){ + return true // sudah init + } else { + val initflag = Bass.BASS_DEVICE_16BITS or Bass.BASS_DEVICE_MONO + return bass.BASS_Init(deviceID, device_samplingrate,initflag) + } + } + } + return false // gagal GetDeviceInfo + } + + /** + * Check if the device is having some handles opened. + * @param deviceID the device ID to check + * @return true if the device is opening something, false otherwise + */ + fun DeviceIsOpeningSomething(deviceID: Int) : Boolean { + return bass.BASS_GetConfig(Bass.BASS_CONFIG_HANDLES) > 0 + + } + + fun Free(){ + bass.BASS_Free() + } + } + init{ logger.info("Bass Version = ${bass.BASS_GetVersion().toHexString()}") } - fun DetectPlaybackDevices() : List> { - val result = ArrayList>() - for(i in 0..10){ - val dev = Bass.BASS_DEVICEINFO() - if (bass.BASS_GetDeviceInfo(i, dev)){ - if (dev.flags and Bass.BASS_DEVICE_ENABLED != 0){ - result.add(Pair(i, dev.name)) - } - } - } - return result - } - fun InitDevice(deviceID: Int, device_samplingrate: Int = 48000) : Boolean { - val dev = Bass.BASS_DEVICEINFO() - if (bass.BASS_GetDeviceInfo(deviceID, dev)) { - if (dev.flags and Bass.BASS_DEVICE_ENABLED > 0){ - if (dev.flags and Bass.BASS_DEVICE_INIT > 0){ - return true // sudah init - } else { - val initflag = Bass.BASS_DEVICE_16BITS or Bass.BASS_DEVICE_MONO - return bass.BASS_Init(deviceID, device_samplingrate,initflag) - } - } - } - return false // gagal GetDeviceInfo - } } \ No newline at end of file diff --git a/src/audio/OpusStreamReceiver.kt b/src/audio/OpusStreamReceiver.kt index 7e14053..70a0ea6 100644 --- a/src/audio/OpusStreamReceiver.kt +++ b/src/audio/OpusStreamReceiver.kt @@ -12,7 +12,7 @@ import org.slf4j.LoggerFactory * @throws Exception if the device cannot be set. */ @Suppress("unused") -class OpusStreamReceiver(deviceID: Int, val samplingrate: Int = 16000) { +class OpusStreamReceiver(val deviceID: Int, val samplingrate: Int = 16000) { private val bass = Bass.Instance private val bassopus = BASSOPUS.Instance private var filehandle = 0 @@ -20,29 +20,29 @@ class OpusStreamReceiver(deviceID: Int, val samplingrate: Int = 16000) { var isPlaying = false - init{ - if (!bass.BASS_SetDevice(deviceID)){ - throw Exception("Failed to set device $deviceID") - } - } + /** * Starts the Opus stream playback. * @return true if the stream started successfully, false otherwise. */ fun Start() : Boolean{ - val opushead = BASSOPUS.BASS_OPUS_HEAD() - opushead.version = 1 - opushead.channels = 1 - opushead.inputrate = samplingrate - val procpush = Pointer(-1) - filehandle = bassopus.BASS_OPUS_StreamCreate(opushead,0, procpush, null) - if (filehandle != 0){ - if (bass.BASS_ChannelPlay(filehandle,false)){ - isPlaying = true - return true - } else logger.error("BASS_ChannelPlay failed for filehandle $filehandle, code ${bass.BASS_ErrorGetCode()}") - } else logger.error("BASS_OPUS_StreamCreate failed, code ${bass.BASS_ErrorGetCode()}") + + if (AudioUtility.InitDevice(deviceID, samplingrate)){ + val opushead = BASSOPUS.BASS_OPUS_HEAD() + opushead.version = 1 + opushead.channels = 1 + opushead.inputrate = samplingrate + val procpush = Pointer(-1) + filehandle = bassopus.BASS_OPUS_StreamCreate(opushead,0, procpush, null) + if (filehandle != 0){ + if (bass.BASS_ChannelPlay(filehandle,false)){ + isPlaying = true + return true + } else logger.error("BASS_ChannelPlay failed for filehandle $filehandle, code ${bass.BASS_ErrorGetCode()}") + } else logger.error("BASS_OPUS_StreamCreate failed, code ${bass.BASS_ErrorGetCode()}") + } else logger.error("Error initializing device $deviceID with sampling rate $samplingrate, code ${bass.BASS_ErrorGetCode()}") + return false } @@ -51,11 +51,11 @@ class OpusStreamReceiver(deviceID: Int, val samplingrate: Int = 16000) { */ fun Stop(){ if (filehandle!=0){ - bass.BASS_ChannelStop(filehandle) - bass.BASS_StreamFree(filehandle) + bass.BASS_ChannelFree(filehandle) filehandle = 0 - isPlaying = false } + isPlaying = false + AudioUtility.Free() } /** diff --git a/src/somecodes/Codes.kt b/src/somecodes/Codes.kt index d59929c..a4435bc 100644 --- a/src/somecodes/Codes.kt +++ b/src/somecodes/Codes.kt @@ -9,6 +9,18 @@ class Codes { companion object{ val audioFilePath = Path(System.getProperty("user.dir"), "audiofile") private val validAudioExtensions = setOf("wav", "mp3") + private val KB_size = 1024 // 1 KB = 1024 bytes + private val MB_size = 1024 * KB_size // 1 MB = 1024 KB + private val GB_size = 1024 * MB_size // 1 GB = 1024 MB + + fun SizeToString(size: Long) : String { + return when { + size >= GB_size -> String.format("%.2f GB", size.toDouble() / GB_size) + size >= MB_size -> String.format("%.2f MB", size.toDouble() / MB_size) + size >= KB_size -> String.format("%.2f KB", size.toDouble() / KB_size) + else -> "$size bytes" + } + } fun getAudioFiles() : Array { val audioDir = audioFilePath.toFile() if (!audioDir.exists()) { diff --git a/src/web/webApp.java b/src/web/webApp.java index d099041..0ea0bcd 100644 --- a/src/web/webApp.java +++ b/src/web/webApp.java @@ -10,7 +10,6 @@ import somecodes.Codes; import java.io.InputStream; import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.Map; @@ -72,7 +71,7 @@ public class webApp { String message = wsMessageContext.message(); try{ var command = objectMapper.readValue(message, WsCommand.class); - logger.info("Received command from pocreceiver.html/ws : {}", command); + //logger.info("Received command from pocreceiver.html/ws : {}", command); var reply = callback.apply("pocreceiver", command); wsMessageContext.send(reply); } catch (Exception e){ diff --git a/src/zello/ZelloClient.kt b/src/zello/ZelloClient.kt index 5b94a5b..4cebde3 100644 --- a/src/zello/ZelloClient.kt +++ b/src/zello/ZelloClient.kt @@ -2,10 +2,6 @@ package zello import com.fasterxml.jackson.module.kotlin.contains import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.java_websocket.client.WebSocketClient import org.java_websocket.handshake.ServerHandshake import org.slf4j.Logger @@ -20,23 +16,27 @@ import java.util.function.BiConsumer /** * ZelloClient is a WebSocket client for connecting to Zello services. * [Source](https://github.com/zelloptt/zello-channel-api/blob/master/API.md) + * @param address the WebSocket address of the Zello server, e.g. "wss://zello.io/ws" + * @param username the username for Zello authentication + * @param password the password for Zello authentication + * @param channel the default channel to join after connecting * */ -class ZelloClient(val address : URI, val username: String, val password: String) { +class ZelloClient(val address : URI, val username: String, val password: String, val channel: String="GtcDev2025") { private val streamJob = HashMap() private val imageJob = HashMap() private val commandJob = HashMap() companion object { - fun fromConsumerZello(username : String, password: String) : ZelloClient { - return ZelloClient(URI.create("wss://zello.io/ws"), username, password) + fun fromConsumerZello(username : String, password: String, channel: String) : ZelloClient { + return ZelloClient(URI.create("wss://zello.io/ws"), username, password, channel) } - fun fromZelloWork(username: String, password: String, networkName : String) : ZelloClient{ - return ZelloClient(URI.create("wss://zellowork.io/ws/$networkName"), username, password) + fun fromZelloWork(username: String, password: String, channel: String, networkName : String) : ZelloClient{ + return ZelloClient(URI.create("wss://zellowork.io/ws/$networkName"), username, password, channel) } - fun fromZelloEnterpriseServer(username: String, password: String, serverDomain: String) : ZelloClient{ - return ZelloClient(URI.create("wss://$serverDomain/ws/mesh"), username, password) + fun fromZelloEnterpriseServer(username: String, password: String, channel: String, serverDomain: String) : ZelloClient{ + return ZelloClient(URI.create("wss://$serverDomain/ws/mesh"), username, password, channel) } } @@ -45,21 +45,29 @@ class ZelloClient(val address : URI, val username: String, val password: String) // this key is temporary, valid only 30 days from 2025-07-29 // if need to create, from https://developers.zello.com/keys private val developerKey : String = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJXa002Y21ScllYSjBiMjV2T2pFLi1yYjJ2THFRbUhYV3dKY2I2azl2TDdUMEtzRWZMRjcxZm5jcktTZ0s2ZE0iLCJleHAiOjE3NTY0MzIyMTIsImF6cCI6ImRldiJ9.ANK7BIS6WVVWsQRjcZXyGWrV2RodCUQD4WXWaA6E4Dlyy8bBCMFdbiKN2D7B_x729HQULailnfRhbXF4Avfg14qONdc1XE_0iGiPUO1kfUSgdd11QylOzjxy6FTKSeZmHOh65JZq2dIWxobCcva-RPvbR8TA656upHh32xrWv9zlU0N707FTca04kze0Iq-q-uC5EL82yK10FEvOPDX88MYy71QRYi8Qh_KbSyMcYAhe2bTsiyjm51ZH9ntkRHd0HNiaijNZI6-qXkkp5Soqmzh-bTtbbgmbX4BT3Qpz_IP3epaX3jl_Aq5DHxXwCsJ9FThif9um5D0TWVGQteR0cQ" - // default channel to join - private var channels = arrayOf("GtcDev2025") // refresh token for the session // this is set after the first LogonReply private var refresh_token: String? = null private val logger : Logger = LoggerFactory.getLogger(ZelloClient::class.java) private val mapper = jacksonObjectMapper() + + var currentChannel: String? = null ; private set + var isOnline: Boolean = false ; private set + var isReceivingStreaming: Boolean = false + var receivingFrom: String? = null; private set + var bytesReceived: Long = 0; private set + fun Start(event: ZelloEvent){ client = object : WebSocketClient(address) { override fun onOpen(handshakedata: ServerHandshake?) { //logger.info("Connected to $address") + isOnline = false + currentChannel = null + isReceivingStreaming = false seqID = 0 inc_seqID() - val lg = LogonCommand.create(seqID,channels, developerKey, username, password) + val lg = LogonCommand.create(seqID,arrayOf(channel), developerKey, username, password) val value = mapper.writeValueAsString(lg) send(value) } @@ -82,6 +90,7 @@ class ZelloClient(val address : URI, val username: String, val password: String) job.pushAudioData(data) event.onStreamingData(job.from, job.For?:"", job.channel, data) } + bytesReceived += data.size } 0x02.toByte() ->{ @@ -130,7 +139,7 @@ class ZelloClient(val address : URI, val username: String, val password: String) refresh_token = lgreply.refresh_token event.onConnected() } else { - logger.error("Failed to logon: ${lgreply.error ?: "Unknown error"}") + event.onError("Failed to logon: ${lgreply.error ?: "Unknown error"}") } } else ->{ @@ -163,6 +172,8 @@ class ZelloClient(val address : URI, val username: String, val password: String) "on_channel_status" -> { val channelstatus = mapper.treeToValue(jsnode, Event_OnChannelStatus::class.java) event.onChannelStatus(channelstatus.channel, channelstatus.status, channelstatus.users_online, channelstatus.error, channelstatus.error_type) + currentChannel = channelstatus.channel + isOnline = channelstatus.status== "online" } "on_error" -> { val error = mapper.treeToValue(jsnode, Event_OnError::class.java) @@ -173,6 +184,9 @@ class ZelloClient(val address : URI, val username: String, val password: String) logger.info("Stream started on channel ${streamstart.channel} from ${streamstart.from} for ${streamstart.For}") streamJob[streamstart.stream_id] = ZelloAudioJob.fromEventOnStreamStart(streamstart) event.onStartStreaming(streamstart.from, streamstart.For, streamstart.channel) + isReceivingStreaming = true + receivingFrom = streamstart.from + bytesReceived = 0 // reset bytes received } "on_stream_stop" -> { val streamstop = mapper.treeToValue(jsnode, Event_OnStreamStop::class.java) @@ -184,6 +198,8 @@ class ZelloClient(val address : URI, val username: String, val password: String) event.onStopStreaming(job.from, job.For?:"", job.channel) event.onAudioData(job.streamID, job.from, job.For?:"", job.channel, job.getAudioData()) } + isReceivingStreaming = false + receivingFrom = null } "on_image" ->{ val image = mapper.treeToValue(jsnode, Event_OnImage::class.java) @@ -209,12 +225,10 @@ class ZelloClient(val address : URI, val username: String, val password: String) override fun onClose(code: Int, reason: String?, remote: Boolean) { logger.info("Closed from ${if (remote) "Remote side" else "Local side"}, Code=$code, Reason=$reason") - event.onDisconnected() - // try reconnecting after 10 seconds - CoroutineScope(Dispatchers.Default).launch { - delay(10000) - connect() - } + event.onDisconnected(reason?: "Unknown reason") + isOnline = false + currentChannel = null + //Revisi 06/08/2025 : Change to Coroutines // val thread = Thread { diff --git a/src/zello/ZelloEvent.kt b/src/zello/ZelloEvent.kt index 504f4ec..d0d5c4f 100644 --- a/src/zello/ZelloEvent.kt +++ b/src/zello/ZelloEvent.kt @@ -9,7 +9,7 @@ interface ZelloEvent { fun onTextMessage(messageID: Int, from: String, For: String, channel: String, text: String, timestamp: Long) fun onLocation(messageID: Int, from: String, For: String, channel: String, latitude: Double, longitude: Double, address:String, accuracy: Double, timestamp: Long ) fun onConnected() - fun onDisconnected() + fun onDisconnected(reason: String) fun onError(errorMessage: String) fun onStartStreaming(from: String, For: String, channel: String) fun onStopStreaming(from: String, For: String, channel: String)