Commit 07/08/2025

This commit is contained in:
2025-08-07 15:04:53 +07:00
parent 446f031535
commit eba4f7852e
12 changed files with 387 additions and 229 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
### IntelliJ IDEA ### ### IntelliJ IDEA ###
out/ out/
audiofile/
!**/src/main/**/out/ !**/src/main/**/out/
!**/src/test/**/out/ !**/src/test/**/out/

View File

@@ -9,6 +9,7 @@
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" /> <sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/testResources" type="java-test-resource" /> <sourceFolder url="file://$MODULE_DIR$/testResources" type="java-test-resource" />
<sourceFolder url="file://$MODULE_DIR$/html" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/html" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/audiofile" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

View File

@@ -1,20 +1,20 @@
$(document).ready(function() { $(document).ready(function() {
// Your code here // Your code here
console.log('pocreceiver.js is ready!'); //console.log('pocreceiver.js is ready!');
const path = window.location.pathname; const path = window.location.pathname;
const ws = new WebSocket('ws://' + window.location.host + path + '/ws'); const ws = new WebSocket('ws://' + window.location.host + path + '/ws');
ws.onopen = function() { ws.onopen = function() {
console.log('WebSocket connection opened'); //console.log('WebSocket connection opened');
$('#indicatorDisconnected').addClass('visually-hidden'); $('#indicatorDisconnected').addClass('visually-hidden');
$('#indicatorConnected').removeClass('visually-hidden'); $('#indicatorConnected').removeClass('visually-hidden');
setInterval(function() { setInterval(function() {
sendCommand({ command: "getZelloStatus" }); sendCommand({ command: "getZelloStatus" });
}, 5000); }, 1000);
}; };
ws.onmessage = function(event) { ws.onmessage = function(event) {
console.log('WebSocket message received:', event.data); //console.log('WebSocket message received:', event.data);
let msg = {}; let msg = {};
try { try {
msg = JSON.parse(event.data); msg = JSON.parse(event.data);
@@ -25,13 +25,13 @@ $(document).ready(function() {
} }
if (msg.reply === "getZelloStatus" && msg.data !== undefined && msg.data.length > 0) { if (msg.reply === "getZelloStatus" && msg.data !== undefined && msg.data.length > 0) {
const zelloData = msg.data; const zelloData = msg.data;
console.log('Zello Status Data:', zelloData); //console.log('Zello Status Data:', zelloData);
$('#zelloStatus').text(zelloData.status); $('#zelloStatus').text(zelloData);
} }
}; };
ws.onclose = function() { ws.onclose = function() {
console.log('WebSocket connection closed'); //console.log('WebSocket connection closed');
$('#indicatorDisconnected').removeClass('visually-hidden'); $('#indicatorDisconnected').removeClass('visually-hidden');
$('#indicatorConnected').addClass('visually-hidden'); $('#indicatorConnected').addClass('visually-hidden');
}; };

View File

@@ -35,7 +35,7 @@
<h1 class="text-center">Zello Status</h1> <h1 class="text-center">Zello Status</h1>
</div> </div>
<div class="row"> <div class="row">
<p id="zelloStatus">Paragraph</p> <p class="d-flex justify-content-center" id="zelloStatus">Paragraph</p>
</div> </div>
</div> </div>
<script src="assets/bootstrap/js/bootstrap.min.js"></script> <script src="assets/bootstrap/js/bootstrap.min.js"></script>

View File

@@ -10,12 +10,14 @@ import web.webApp
import zello.ZelloClient import zello.ZelloClient
import zello.ZelloEvent import zello.ZelloEvent
import javafx.util.Pair 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 org.slf4j.LoggerFactory
import somecodes.Codes import somecodes.Codes
import web.WsCommand import web.WsCommand
import java.util.function.BiFunction import java.util.function.BiFunction
import kotlin.io.path.isRegularFile
import kotlin.io.path.pathString
//TIP To <b>Run</b> code, press <shortcut actionId="Run"/> or //TIP To <b>Run</b> code, press <shortcut actionId="Run"/> or
// click the <icon src="AllIcons.Actions.Execute"/> icon in the gutter. // click the <icon src="AllIcons.Actions.Execute"/> icon in the gutter.
@@ -25,23 +27,140 @@ fun main() {
val cfg = configFile() val cfg = configFile()
cfg.Load() cfg.Load()
val au = AudioUtility()
var audioID = 0 var audioID = 0
val preferedAudioDevice = "Speakers" 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)) { if (pair.second.contains(preferedAudioDevice)) {
audioID = pair.first audioID = pair.first
} }
} }
if (audioID!=0){ if (audioID!=0){
val initsuccess = au.InitDevice(audioID,44100) val initsuccess = AudioUtility.InitDevice(audioID,44100)
println("Audio Device $audioID initialized: $initsuccess") logger.info("Audio Device $audioID initialized: $initsuccess")
} }
// for Zello Client
val o = OpusStreamReceiver(audioID) val o = OpusStreamReceiver(audioID)
// for AudioFilePlayer
var afp: AudioFilePlayer? = null 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 { val w = webApp("0.0.0.0",3030, BiFunction {
source: String, cmd: WsCommand -> source: String, cmd: WsCommand ->
when (source) { when (source) {
@@ -70,14 +189,39 @@ fun main() {
"setZelloConfig" -> { "setZelloConfig" -> {
try{ try{
val xx = objectMapper.readValue(cmd.data, object: TypeReference<Map<String, String>>() {}) val xx = objectMapper.readValue(cmd.data, object: TypeReference<Map<String, String>>() {})
cfg.ZelloUsername = xx["ZelloUsername"] var changed = false
cfg.ZelloPassword = xx["ZelloPassword"] if (cfg.ZelloUsername != xx["ZelloUsername"]) {
cfg.ZelloChannel = xx["ZelloChannel"] cfg.ZelloUsername = xx["ZelloUsername"] ?: ""
cfg.ZelloServer = xx["ZelloServer"] changed = true
cfg.ZelloWorkNetworkName = xx["ZelloWorkNetworkName"] }
cfg.ZelloEnterpriseServerDomain = xx["ZelloEnterpriseServerDomain"] if (cfg.ZelloPassword != xx["ZelloPassword"]) {
cfg.Save() cfg.ZelloPassword = xx["ZelloPassword"] ?: ""
WsReply(cmd.command,"success") 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){ } catch (e: Exception){
WsReply(cmd.command,"failed: ${e.message}") WsReply(cmd.command,"failed: ${e.message}")
} }
@@ -87,16 +231,43 @@ fun main() {
try{ try{
val xx = objectMapper.readValue(cmd.data, object : TypeReference<Map<String, String>>() {}) val xx = objectMapper.readValue(cmd.data, object : TypeReference<Map<String, String>>() {})
cfg.M1 = xx["M1"] var changed = false
cfg.M2 = xx["M2"] if (cfg.M1 != xx["M1"]) {
cfg.M3 = xx["M3"] cfg.M1 = xx["M1"] ?: ""
cfg.M4 = xx["M4"] changed = true
cfg.M5 = xx["M5"] }
cfg.M6 = xx["M6"] if (cfg.M2 != xx["M2"]) {
cfg.M7 = xx["M7"] cfg.M2 = xx["M2"] ?: ""
cfg.M8 = xx["M8"] changed = true
cfg.Save() }
WsReply(cmd.command,"success") 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){ } catch (e: Exception){
WsReply(cmd.command,"failed: ${e.message}") WsReply(cmd.command,"failed: ${e.message}")
} }
@@ -121,12 +292,18 @@ fun main() {
} }
"getPlaybackStatus" ->{ "getPlaybackStatus" ->{
if (afp!=null && true==afp?.isPlaying){ 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 { } else {
WsReply(cmd.command, "Idle") WsReply(cmd.command, "Idle")
} }
} }
"playMessage" ->{ "playMessage" ->{
afp?.Stop()
afp = null
// stop Opus Receiver if it is running
o.Stop()
val filename = when(cmd.data){ val filename = when(cmd.data){
"M1" -> cfg.M1 "M1" -> cfg.M1
"M2" -> cfg.M2 "M2" -> cfg.M2
@@ -141,24 +318,21 @@ fun main() {
} }
} }
if (filename!=null){ if (filename!=null){
val completefilename = Codes.audioFilePath.resolve(filename) try{
if (completefilename.isRegularFile()){ afp= AudioFilePlayer(audioID, filename)
try{ afp?.Play { _ -> afp = null}
WsReply(cmd.command,"success")
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}")
} 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" ->{ "stopMessage" ->{
@@ -169,6 +343,16 @@ fun main() {
else -> WsReply(cmd.command,"Invalid command: ${cmd.command}") else -> WsReply(cmd.command,"Invalid command: ${cmd.command}")
} }
"pocreceiver" -> when(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 command: ${cmd.command}")
} }
else -> WsReply(cmd.command,"Invalid source: $source") else -> WsReply(cmd.command,"Invalid source: $source")
@@ -177,76 +361,11 @@ fun main() {
} , Pair("admin","admin1234")) } , Pair("admin","admin1234"))
w.Start() 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)
}
})
} }

View File

@@ -4,77 +4,67 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import somecodes.Codes
import java.util.function.Consumer 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. * Audio Player for playing audio files.
* Supported extensions : .wav, .mp3 * Supported extensions : .wav, .mp3
*/ */
@Suppress("unused") @Suppress("unused")
class AudioFilePlayer(deviceID: Int, filename: String, device_samplingrate: Int = 48000) { class AudioFilePlayer(deviceID: Int, val filename: String, device_samplingrate: Int = 48000) {
val bass: Bass = Bass.Instance private val bass: Bass = Bass.Instance
var filehandle = 0 private var filehandle = 0
var isPlaying = false var isPlaying = false
private val filepath = Path(filename) val fileSize: Long
val duration: Double
var elapsed: Double = 0.0
init{ init{
if (bass.BASS_SetDevice(deviceID)){ val fullpath = Codes.audioFilePath.resolve(filename)
filehandle = bass.BASS_StreamCreateFile(false, filename, 0, 0, 0) if (fullpath.exists()){
if (filehandle == 0) { if (AudioUtility.InitDevice(deviceID, device_samplingrate)) {
throw Exception("Failed to create stream for file $filename: ${bass.BASS_ErrorGetCode()}") filehandle = bass.BASS_StreamCreateFile(false, fullpath.absolutePathString(), 0, 0, 0)
} if (filehandle!=0){
} else throw Exception("Failed to set device $deviceID") 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(){ fun Stop(){
if (filehandle!=0){ if (filehandle!=0){
bass.BASS_ChannelStop(filehandle) bass.BASS_ChannelFree(filehandle)
bass.BASS_StreamFree(filehandle)
filehandle = 0 filehandle = 0
} }
} isPlaying = false
AudioUtility.Free()
fun ShortFileName() : String {
return filepath.fileName.toString()
} }
fun Play(finished: Consumer<Any> ) : Boolean{ fun Play(finished: Consumer<Any> ) : Boolean{
if (bass.BASS_ChannelPlay(filehandle, false)){ if (bass.BASS_ChannelPlay(filehandle, false)){
elapsed = 0.0
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
isPlaying = true isPlaying = true
while(true){ while(true){
delay(1000) delay(50)
if (bass.BASS_ChannelIsActive(filehandle)!= Bass.BASS_ACTIVE_PLAYING){ if (bass.BASS_ChannelIsActive(filehandle)!= Bass.BASS_ACTIVE_PLAYING){
// finished playing // finished playing
break break
} }
elapsed = bass.BASS_ChannelBytes2Seconds(filehandle, bass.BASS_ChannelGetPosition(filehandle, Bass.BASS_POS_BYTE))
} }
isPlaying = false isPlaying = false
bass.BASS_StreamFree(filehandle) bass.BASS_ChannelFree(filehandle)
filehandle = 0 filehandle = 0
finished.accept(true) 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 true
} }
return false return false

View File

@@ -3,39 +3,61 @@ import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
class AudioUtility { class AudioUtility {
private val bass = Bass.Instance
private val logger : Logger = LoggerFactory.getLogger(AudioUtility::class.java) private val logger : Logger = LoggerFactory.getLogger(AudioUtility::class.java)
companion object{
private val bass = Bass.Instance
fun DetectPlaybackDevices() : List<Pair<Int, String>> {
val result = ArrayList<Pair<Int, String>>()
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{ init{
logger.info("Bass Version = ${bass.BASS_GetVersion().toHexString()}") logger.info("Bass Version = ${bass.BASS_GetVersion().toHexString()}")
} }
fun DetectPlaybackDevices() : List<Pair<Int, String>> {
val result = ArrayList<Pair<Int, String>>()
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
}
} }

View File

@@ -12,7 +12,7 @@ import org.slf4j.LoggerFactory
* @throws Exception if the device cannot be set. * @throws Exception if the device cannot be set.
*/ */
@Suppress("unused") @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 bass = Bass.Instance
private val bassopus = BASSOPUS.Instance private val bassopus = BASSOPUS.Instance
private var filehandle = 0 private var filehandle = 0
@@ -20,29 +20,29 @@ class OpusStreamReceiver(deviceID: Int, val samplingrate: Int = 16000) {
var isPlaying = false var isPlaying = false
init{
if (!bass.BASS_SetDevice(deviceID)){
throw Exception("Failed to set device $deviceID")
}
}
/** /**
* Starts the Opus stream playback. * Starts the Opus stream playback.
* @return true if the stream started successfully, false otherwise. * @return true if the stream started successfully, false otherwise.
*/ */
fun Start() : Boolean{ fun Start() : Boolean{
val opushead = BASSOPUS.BASS_OPUS_HEAD()
opushead.version = 1 if (AudioUtility.InitDevice(deviceID, samplingrate)){
opushead.channels = 1 val opushead = BASSOPUS.BASS_OPUS_HEAD()
opushead.inputrate = samplingrate opushead.version = 1
val procpush = Pointer(-1) opushead.channels = 1
filehandle = bassopus.BASS_OPUS_StreamCreate(opushead,0, procpush, null) opushead.inputrate = samplingrate
if (filehandle != 0){ val procpush = Pointer(-1)
if (bass.BASS_ChannelPlay(filehandle,false)){ filehandle = bassopus.BASS_OPUS_StreamCreate(opushead,0, procpush, null)
isPlaying = true if (filehandle != 0){
return true if (bass.BASS_ChannelPlay(filehandle,false)){
} else logger.error("BASS_ChannelPlay failed for filehandle $filehandle, code ${bass.BASS_ErrorGetCode()}") isPlaying = true
} else logger.error("BASS_OPUS_StreamCreate failed, code ${bass.BASS_ErrorGetCode()}") 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 return false
} }
@@ -51,11 +51,11 @@ class OpusStreamReceiver(deviceID: Int, val samplingrate: Int = 16000) {
*/ */
fun Stop(){ fun Stop(){
if (filehandle!=0){ if (filehandle!=0){
bass.BASS_ChannelStop(filehandle) bass.BASS_ChannelFree(filehandle)
bass.BASS_StreamFree(filehandle)
filehandle = 0 filehandle = 0
isPlaying = false
} }
isPlaying = false
AudioUtility.Free()
} }
/** /**

View File

@@ -9,6 +9,18 @@ class Codes {
companion object{ companion object{
val audioFilePath = Path(System.getProperty("user.dir"), "audiofile") val audioFilePath = Path(System.getProperty("user.dir"), "audiofile")
private val validAudioExtensions = setOf("wav", "mp3") 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<String> { fun getAudioFiles() : Array<String> {
val audioDir = audioFilePath.toFile() val audioDir = audioFilePath.toFile()
if (!audioDir.exists()) { if (!audioDir.exists()) {

View File

@@ -10,7 +10,6 @@ import somecodes.Codes;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -72,7 +71,7 @@ public class webApp {
String message = wsMessageContext.message(); String message = wsMessageContext.message();
try{ try{
var command = objectMapper.readValue(message, WsCommand.class); 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); var reply = callback.apply("pocreceiver", command);
wsMessageContext.send(reply); wsMessageContext.send(reply);
} catch (Exception e){ } catch (Exception e){

View File

@@ -2,10 +2,6 @@ package zello
import com.fasterxml.jackson.module.kotlin.contains import com.fasterxml.jackson.module.kotlin.contains
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 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.client.WebSocketClient
import org.java_websocket.handshake.ServerHandshake import org.java_websocket.handshake.ServerHandshake
import org.slf4j.Logger import org.slf4j.Logger
@@ -20,23 +16,27 @@ import java.util.function.BiConsumer
/** /**
* ZelloClient is a WebSocket client for connecting to Zello services. * ZelloClient is a WebSocket client for connecting to Zello services.
* [Source](https://github.com/zelloptt/zello-channel-api/blob/master/API.md) * [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<Int, ZelloAudioJob>() private val streamJob = HashMap<Int, ZelloAudioJob>()
private val imageJob = HashMap<Int, ZelloImageJob>() private val imageJob = HashMap<Int, ZelloImageJob>()
private val commandJob = HashMap<Int, Any>() private val commandJob = HashMap<Int, Any>()
companion object { companion object {
fun fromConsumerZello(username : String, password: String) : ZelloClient { fun fromConsumerZello(username : String, password: String, channel: String) : ZelloClient {
return ZelloClient(URI.create("wss://zello.io/ws"), username, password) return ZelloClient(URI.create("wss://zello.io/ws"), username, password, channel)
} }
fun fromZelloWork(username: String, password: String, networkName : String) : ZelloClient{ fun fromZelloWork(username: String, password: String, channel: String, networkName : String) : ZelloClient{
return ZelloClient(URI.create("wss://zellowork.io/ws/$networkName"), username, password) return ZelloClient(URI.create("wss://zellowork.io/ws/$networkName"), username, password, channel)
} }
fun fromZelloEnterpriseServer(username: String, password: String, serverDomain: String) : ZelloClient{ fun fromZelloEnterpriseServer(username: String, password: String, channel: String, serverDomain: String) : ZelloClient{
return ZelloClient(URI.create("wss://$serverDomain/ws/mesh"), username, password) 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 // this key is temporary, valid only 30 days from 2025-07-29
// if need to create, from https://developers.zello.com/keys // 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" 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 // refresh token for the session
// this is set after the first LogonReply // this is set after the first LogonReply
private var refresh_token: String? = null private var refresh_token: String? = null
private val logger : Logger = LoggerFactory.getLogger(ZelloClient::class.java) private val logger : Logger = LoggerFactory.getLogger(ZelloClient::class.java)
private val mapper = jacksonObjectMapper() 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){ fun Start(event: ZelloEvent){
client = object : WebSocketClient(address) { client = object : WebSocketClient(address) {
override fun onOpen(handshakedata: ServerHandshake?) { override fun onOpen(handshakedata: ServerHandshake?) {
//logger.info("Connected to $address") //logger.info("Connected to $address")
isOnline = false
currentChannel = null
isReceivingStreaming = false
seqID = 0 seqID = 0
inc_seqID() 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) val value = mapper.writeValueAsString(lg)
send(value) send(value)
} }
@@ -82,6 +90,7 @@ class ZelloClient(val address : URI, val username: String, val password: String)
job.pushAudioData(data) job.pushAudioData(data)
event.onStreamingData(job.from, job.For?:"", job.channel, data) event.onStreamingData(job.from, job.For?:"", job.channel, data)
} }
bytesReceived += data.size
} }
0x02.toByte() ->{ 0x02.toByte() ->{
@@ -130,7 +139,7 @@ class ZelloClient(val address : URI, val username: String, val password: String)
refresh_token = lgreply.refresh_token refresh_token = lgreply.refresh_token
event.onConnected() event.onConnected()
} else { } else {
logger.error("Failed to logon: ${lgreply.error ?: "Unknown error"}") event.onError("Failed to logon: ${lgreply.error ?: "Unknown error"}")
} }
} }
else ->{ else ->{
@@ -163,6 +172,8 @@ class ZelloClient(val address : URI, val username: String, val password: String)
"on_channel_status" -> { "on_channel_status" -> {
val channelstatus = mapper.treeToValue(jsnode, Event_OnChannelStatus::class.java) val channelstatus = mapper.treeToValue(jsnode, Event_OnChannelStatus::class.java)
event.onChannelStatus(channelstatus.channel, channelstatus.status, channelstatus.users_online, channelstatus.error, channelstatus.error_type) event.onChannelStatus(channelstatus.channel, channelstatus.status, channelstatus.users_online, channelstatus.error, channelstatus.error_type)
currentChannel = channelstatus.channel
isOnline = channelstatus.status== "online"
} }
"on_error" -> { "on_error" -> {
val error = mapper.treeToValue(jsnode, Event_OnError::class.java) 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}") logger.info("Stream started on channel ${streamstart.channel} from ${streamstart.from} for ${streamstart.For}")
streamJob[streamstart.stream_id] = ZelloAudioJob.fromEventOnStreamStart(streamstart) streamJob[streamstart.stream_id] = ZelloAudioJob.fromEventOnStreamStart(streamstart)
event.onStartStreaming(streamstart.from, streamstart.For, streamstart.channel) event.onStartStreaming(streamstart.from, streamstart.For, streamstart.channel)
isReceivingStreaming = true
receivingFrom = streamstart.from
bytesReceived = 0 // reset bytes received
} }
"on_stream_stop" -> { "on_stream_stop" -> {
val streamstop = mapper.treeToValue(jsnode, Event_OnStreamStop::class.java) 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.onStopStreaming(job.from, job.For?:"", job.channel)
event.onAudioData(job.streamID, job.from, job.For?:"", job.channel, job.getAudioData()) event.onAudioData(job.streamID, job.from, job.For?:"", job.channel, job.getAudioData())
} }
isReceivingStreaming = false
receivingFrom = null
} }
"on_image" ->{ "on_image" ->{
val image = mapper.treeToValue(jsnode, Event_OnImage::class.java) 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) { override fun onClose(code: Int, reason: String?, remote: Boolean) {
logger.info("Closed from ${if (remote) "Remote side" else "Local side"}, Code=$code, Reason=$reason") logger.info("Closed from ${if (remote) "Remote side" else "Local side"}, Code=$code, Reason=$reason")
event.onDisconnected() event.onDisconnected(reason?: "Unknown reason")
// try reconnecting after 10 seconds isOnline = false
CoroutineScope(Dispatchers.Default).launch { currentChannel = null
delay(10000)
connect()
}
//Revisi 06/08/2025 : Change to Coroutines //Revisi 06/08/2025 : Change to Coroutines
// val thread = Thread { // val thread = Thread {

View File

@@ -9,7 +9,7 @@ interface ZelloEvent {
fun onTextMessage(messageID: Int, from: String, For: String, channel: String, text: String, timestamp: Long) 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 onLocation(messageID: Int, from: String, For: String, channel: String, latitude: Double, longitude: Double, address:String, accuracy: Double, timestamp: Long )
fun onConnected() fun onConnected()
fun onDisconnected() fun onDisconnected(reason: String)
fun onError(errorMessage: String) fun onError(errorMessage: String)
fun onStartStreaming(from: String, For: String, channel: String) fun onStartStreaming(from: String, For: String, channel: String)
fun onStopStreaming(from: String, For: String, channel: String) fun onStopStreaming(from: String, For: String, channel: String)