Share Audio Clips Between Gateway and Clients

The sound player component will hold the sound data on-heap once you pass it some soundData property; in your case, via the runScript binding. This is exactly equivalent to using system.util.globals - it's on heap durable storage. So basically exactly the same performance characteristics. Except, I'd argue, potentially less steps involved.

I would probably approach this with something like:

Gateway

def handleMessage(payload):
	from java.nio.file import Files, Path

	folder = "whatever"
	fileStream = Files.list(Path.of(folder))
	try:
		return {
			str(path.fileName): system.file.readFileAsBytes(str(path))
			for path in fileStream.toList()
			if str(path.fileName).upper().endswith('.WAV')
		}
	finally:
		fileStream.close()

Client

def loadSoundCache():
	system.util.globals["sounds"] = system.util.sendRequest('GatewayScripts', 'GetAlarmSounds')
	
def getAvailableAlarmSounds():
	files = [
		[filename, filename]
		for filename in system.util.globals["sounds"].keys()
	]
	return system.dataset.toDataSet(['Value', 'Label'], files)

Then in a client tag event script, or whatever other event source, you just pass the bytes from globals directly to system.util.playSoundClip:

def playSound(filename, volume = None, block = None):
	soundData = system.util.globals["sounds"][filename]
	if soundData is None:
		raise ValueError("Invalid filename %s" % filename)
	if volume and block:
		system.util.playSoundClip(soundData, volume, block)
	elif volume:
		system.util.playSoundClip(soundData, volume)
	else:
		system.util.playSoundClip(soundData)
1 Like

We use a network share on the gateway to hold all of the wav files.
Then we use system.util.sendMessage from scripts on the gateway to send the file name, to the client designated as the machine to play the WAV on for that alarm area.

It is a little more complex due to us having multiple different production areas at each plant with their own alarms and what we call "voice alarms"

Scripts on the gateway will scan alarms as they come active, see if there is an assigned voice alarm for that alarm, then put it in a dataset using FIFO for the system to send the message to the clients.
The gateway watches that dataset for records, sends the record to the client(s), and deletes the row from the dataset.

So if I'm understanding, what you're mainly doing differently is that you're caching all the sounds locally (and refreshing the list "on demand" somewhere if they want to get the latest sounds), then just passing the bytes from memory rather than hitting the gateway for them?

Well, and you're using the scripted function/event to play the audio once, whereas I want ours to continuously loop until the alarm goes away.

Are the clients loading the sound file from the network share or via a sendMessage? I guess that works, but I would think the sendMessage method would be more cross-platform capable and doesn't require a network share at all (so maybe a bit more "secure" as I like to keep anything I don't really need turned off/disabled).

It sends the file name and path, the clients load them from the share on the gateway. Nothing stored in memory, the config is basically just a table with the alarm path, client to play on, wav file path and name.
Stored procedure that accepts the alarm path as the parameter, returns the client, and wav file path.
Nice little vision window for configuring it in runtime.

Keeps it simple in that it is only one location for the files.
We then also can copy the generic wav files to different plants easier.
60+ gateways I tend to try and keep it as simple as possible.

I have often thought how this could be a great module that a certain person could create and sell.. cough @pturmel cough :stuck_out_tongue:

1 Like

Paul's dissertation in #21 is pretty much what I'd do in this situation. I'd probably add a file watcher to that folder to auto-convert any MP3 dropped into that gateway folder, with a broadcast to clients to refresh their cache.

But then, I don't rely on any PC-based client to play alarm sounds. That's a job for dedicated hardware attached to the PLC.

I used to use alarm horns, but on all the big gas plants I work on anymore, the operators are in a cushy control room away from the plant and the only audible plant horn is for ESD/Evacuations, and tornados (although they do have a trigger for standard alarms, they always keep it turned off unless everyone's leaving the control room). Plus they tend to like some "alternative" sounds at the plants like Michael Scott from the office yelling "No God, No!", or the most annoying sound in the world from Dumb and Dumber. There are a few others, but those 2 seem to be the favorites to get their attention yet make their work lives a little more enjoyable.

2 Likes

That's the sort of stuff I'm want to do as well. Curious what other fun sounds you might have?

The other 2 we have right now are the dying sounds from Mario Bros and PacMan, plus just a standard alarm sound that's your boring typical alarm sound.

1 Like

We have someone with a calm voice record the alarm wav files.

The interface

Trying to do something very similar, but in my own twisted way.

Here's the basic steps.

  1. Client requests the available sounds from the gateway (as a list)
  2. Client uses this list, and for each sound that is available:
    a. Get the sound file in bytes from the gateway
    b. Write the sound file to the client

I've got all scripts in my project library Sound:

import os
import errno

alarmSoundPath = 'C:\SCADA\Alarm Sounds'
alarmSoundOptionsTagPath = '[client]Alarms/AlarmSoundOptions'

def alarmSoundPathExists():
	'''
	Checks to see if the alarmSoundPath folder exists. Creates it if it does not.
	
	Args:
		n/a
		
	Returns
		folder	(bool)		:	true if file path exists, or was successfully created
	
	'''
	path = alarmSoundPath
	
	#check to see that file path exists
	folder = False
	if not os.path.exists(path):
		#create the folder if it doesn't exist
		try:
			os.makedirs(path)
			folder = True
		except OSError as e:
			if errno.EEXIST != e.errno:
				raise
	#folder exists, continue
	else:
		folder = True
		
	return folder



def getAlarmSoundList():
	'''
	Get a list of alarm sounds that are available at the alarmSoundPath
	
	Args:
		n/a
		
	Returns:
		fileList	(string list)	:	list of WAV file names in the directory
	'''
	path = alarmSoundPath
	
	#check to see that file path exists
	folder = alarmSoundPathExists()
		
	#create the file list for return
	if folder:
		fileList = [ name.split('.')[0] for name in os.listdir(path) if name.upper().endswith('.WAV') ]
	else:
		fileList = []
	
	return fileList



def getAlarmSoundDataset():
	'''
	Get a dataset of alarm sounds that are available at the alarmSoundPath
	
	Args:
		n/a
		
	Returns:
		dataset		(dataset)		:	file names as dataset for use with a dropdown
	'''
	path = alarmSoundPath
	
	headers = ['Value','Label']
	
	#get file list
	fileList = getAlarmSoundList()
	
	#need a list of lists to match the headers
	data = [[i, name] for i, name in enumerate(fileList)]
	
	#return dataset
	return system.dataset.toDataSet(headers, data)




##################################################################################
############ functions for clients requesting sounds from the gateway ############
##################################################################################

def requestAvailableAlarmSounds():
	'''
	Request the list of available sounds from the gateway via message handler
	
	Args:
		n/a
		
	Returns:
		fileList	(string list)	:	list of WAV file names in the directory
	'''
	return system.util.sendRequest('Project', 'SendAlarmSoundList')



def retrieveSoundBytes(fileName):
	'''
	Get a sound file from the gateway via message handler
	
	Args:
		fileName	(string)			:	alarm sound name to retrieve
	
	Returns:
		soundBytes	(byte array)		:	sound file in byte format
	'''
	return system.util.sendRequest('Project', 'SendAlarmSound', {'fileName':fileName})



def writeSoundBytes(filePath, soundBytes):
	'''
	Write sound file to the local client at the alarmSoundPath
	
	Args:
		filePath	(string)		:	file to create
		soundBytes	(byte array)	:	sound file in byte format
		
	Returns:
		n/a
	'''
	#check to see that file path exists
	folder = alarmSoundPathExists()
	
	if folder:
		system.file.writeFile(filePath, soundBytes)



def updateClientAlarmSounds():
	'''
	Update alarm sound files on the local client
	'''
	path = alarmSoundPath
	tagPath = alarmSoundOptionsTagPath
	
	fileList = requestAvailableAlarmSounds()
	for fileName in fileList:
		filePath = path + '\\' + fileName + '.wav'
		soundBytes = retrieveSoundBytes(fileName)
		writeSoundBytes(filePath, soundBytes)
		
	#update dataset for options dropdown
	dataset = getAlarmSoundDataset()
	system.tag.writeBlocking([tagPath], [dataset])

##################################################################################
####### functions for the gateway responding to client requests for sounds #######
##################################################################################

def retrieveAlarmSoundList_MH():
	'''
	Message handler to retrieve the list of available sound files on the gateway
	
	Args:
		n/a
		
	Returns:
		fileList	(string list)	:	list of WAV file names in the directory
	'''
	return getAlarmSoundList()



def retrieveAlarmSound_MH(payload):
	'''
	Message handler to retrieve sound file from the gateway
	
	Args:
		payload		(dict)			:	payload 'fileName' for alarm sound file
		
	Returns:
		soundBytes	(byte array)	:	sound file in byte format
	'''
	path = alarmSoundPath
	
	if payload['fileName']:
		filePath = path + '\\' + payload['fileName'] + '.wav'
		if system.file.fileExists(filePath):
			return system.file.readFileAsBytes(filePath)

Gateway Message Handlers:
SendAlarmSound

return Sound.retrieveAlarmSound_MH(payload)

SendAlarmSoundList

return Sound.retrieveAlarmSoundList_MH()

Client Tag Change Script (so I can trigger it at-will):

Sound.updateClientAlarmSounds()

It's not populating. Where might I see some error messages for these?

In a few places, you're using double backslashes, but in the alarmSoundPath variable, you're not. I don't recall if you need the double backslashes, but my guess would be the inconsistencies could be breaking it. Also, I would add some logging throughout and monitor the client and server logs to see what it's seeing.

double backslashes are used occasionally to break the escape./' is the scape for " ' "

If I don't use it I get a syntax error

edit: just typos. I had misspelled retrieve and I used filepath instead of filePath in 2 places. It works!

Ahh, yeah, I wasn't thinking of that (the \' being used to escape the single quote).
Those little typos can get you sometimes!

A proof of concept here.

  • Use case in this example is one sound for an andon call (need components, call team leader, etc) and one as an alarm when a barcode doesn't match.

  • Two examples given for loading wav data from URLs or from file locations. Both load into the globals as a dictionary with keys 'call' and 'scan'

  • Starting a sound will use start('call') or start('scan')

  • Stopping a sound will use stop('call') or stop('scan'). Using stop() with no parameter will stop all clips.

from java.io import ByteArrayInputStream
from java.net import URL
from javax.sound.sampled import AudioSystem
from javax.sound.sampled import AudioInputStream
from javax.sound.sampled import Clip

def loadUrls():
	''' Load a set of wav urls to the global dictionary '''
	
	urlDict = {'call': 'http://192.168.140.35/music/tv/GalaxyQuest.wav',
	           'scan': 'http://192.168.140.35/music/tv/scan_alert_short.wav'
	          }
	audioDict = {}
	for key, value in urlDict.items():
		AudioClip = AudioSystem.getClip()
		audioStream = AudioSystem.getAudioInputStream(URL(value))
		AudioClip.open(audioStream)
		audioDict[key] = AudioClip
	system.util.globals['audio'] = audioDict
	
def loadFiles():
	''' Load a set of wav files to the global dictionary '''
	
	fileDict = {'call': '/mnt/d/music/Andon music/GalaxyQuest.wav',
	            'scan': '/mnt/d/music/Andon music/scan_alert_short.wav'
	           }	
	audioDict = {}
	for key, value in fileDict.items():
		AudioClip = AudioSystem.getClip()
		inputStream = ByteArrayInputStream(system.file.readFileAsBytes(value))
		audioStream = AudioSystem.getAudioInputStream(inputStream)
		AudioClip.open(audioStream)
		audioDict[key] = AudioClip
	system.util.globals['audio'] = audioDict


def start(sound):
	system.util.globals['audio'][sound].setMicrosecondPosition(0)
	system.util.globals['audio'][sound].loop(Clip.LOOP_CONTINUOUSLY)
	
def stop(sound = None):
	if sound is not None:
		system.util.globals['audio'][sound].stop()
	else:
		for key in system.util.globals['audio'].keys():
			system.util.globals['audio'][key].stop()
2 Likes