playSoundClip Repeatedly

A continuation of the original question on this thread.

I can now share audio files between the gateway and the clients rather automatically via a client tag change script.

I am also now able to play the file on the client via system.util.playSoundClip().

The question is now, since these are all clips of varying length, how do I get the sound clip to play repeatedly?

I can imagine doing something rather archaic and toggling a client tag right after the playSoundClip call, which then triggers a check of the tag that originally played the clip to play it again.

Any other thoughts?

A wave file has a close correlation between size and duration. Estimate duration, add a partial second, save that timestamp for deferring interim status checks.

(Use a client timer script to check conditions.)

1 Like

Possibly worse, but also effective method:
Kick off an asynchronous thread that's looping the given sound infinitely.
In between each loop, check whether some sentinel value (e.g. a client tag) has become unset. If it has, return out of your function - the thread will die, the sound will stop.

This won't allow you to interrupt a sound that's actively playing; whether that's a concern or not is up to you.

I just use the sound player component with the tricks I mentioned because it just "works" besides the quirks with multiple docks and logging in/swapping users.

I know that function has a wait parameter to cause it to be blocking, but I don't know that you'd want to block execution due to the other complications it could/will cause.

1 Like

I think that's essentially what I've tried to do but I haven't gotten there yet.

The sounds are less than 5 seconds, so I don't care if it runs until it's over instead of interrupting.

Client tag change script:

'''
triggers:
[client]Alarms/Alarm_Sound_EN
[System]Gateway/Alarming/Active and Unacked
[client]Alarms/AlarmSoundPlayer
'''
if not initialChange:
	system.util.invokeAsynchronous(Sound.playAlarmSoundLoop)

Library script:

def playAlarmSoundLoop():
	'''
	Continiously play the selected alarm sound
	'''
	path = alarmSoundPath
	
	tagsToRead = [
					'[client]Alarms/Alarm_Sound',
					'[client]Alarms/Sound_Volume',
					'[client]Alarms/Alarm_Sound_EN',
					'[System]Gateway/Alarming/Active and Unacked',
					'[client]Alarms/AlarmSoundPlayer',
					'[client]Alarms/AlarmSoundOptions',
				]
				
	selection, volume, EN, count, player, options = [tag.value for tag in system.tag.readBlocking(tagsToRead)]
	
	if EN:
		if count > 0:
			if options.rowCount > 0 and selection:
				wavFile = path + '\\' + selection + '.wav'
				system.util.playSoundClip(wavFile, volume/100.0, True)
				
			system.tag.writeBlocking([alarmSoundPlayerTagPath], [(player % 10) + 1])
			
	return

It'll run once when I call it from the script console but I get nothing from a vision client.

I was hoping that writing back to the player tag would retrigger this script after it finished, checking again to see if the alarm noise is enabled, there are active unacked alarms, and there is at least one valid option for alarm noise selection.

The last argument to playSoundClip needs to be True for it to block execution until the sound plays. Can't speak to the rest of the logic without a closer look, but that's immediately wrong.

I didn't even read the documentation for wait :man_facepalming:. I thought it would be a play delay in ms. I'll see if that works.

I think I posted the original to the wrong thread, but you may find it useful.

1 Like

My script was behaving pretty erratically.

It doesn't matter if I call async or not.

After saving in my designer (client updated), the first transition for Alarm_Sound_EN from 0 to 1 did not play the sound. Toggling back to 0 and then to 1 again, the sound played once. If I left the tag at 1 it started looping, eventually. Then, at some point it started playing the sound multiple times over itself.

I removed the async call and the check for initial change and it started working. - Do I need the async call? Will performance be impacted leaving it sync?

Using the async call again, it'll start working again, but then at some point it'll start overlapping itself again. I can't seem to figure out exactly what it is that causes it.

What if for your tagChange event, you only look for the transition to 1/True to initially call the script asynchronously, then in the script, instead of writing back out to the tag, if it needs to play again (make sure to use wait), then you just call itself again, then return to end that instance of the script, otherwise you just return if it doesn't need to play anymore.

2 Likes

That's perfect! Thanks.

So final implementation.

tags:

[client]Alarms/Alarm_Sound				String		selected alarm sound name, no extension
[client]Alarms/Alarm_Sound_EN			Boolean		alarm sound enabled
[client]Alarms/AlarmSoundOptions		Dataset		dataset of alarm sound name options [Value, Label]
[client]Alarms/Sound_Volume				Double		volume level 0 - 100

Sound library:

import os
import errno

alarmSoundPath = 'C:\SCADA\Alarm Sounds'

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
	'''
	#call for gateway to send the list of available alarm sounds
	return system.util.sendRequest('Fargo', '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
	'''
	#call for gateway to send the alarm sound file in byte format
	return system.util.sendRequest('Fargo', '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:
		#write the file
		system.file.writeFile(filePath, soundBytes)



def updateClientAlarmSounds():
	'''
	Update alarm sound files on the local client
	
	Args:
		n/a
		
	Returns:
		n/a
	'''
	path = alarmSoundPath
	tagPath = '[client]Alarms/AlarmSoundOptions'
	
	#remove options until sound files are written - it takes some time
	blankDataset = []
	system.tag.writeBlocking([tagPath], [blankDataset])
	
	#get the list of available sound files on the gateway
	fileList = requestAvailableAlarmSounds()
	
	#for each file
	for fileName in fileList:
		#generate path to the file
		filePath = path + '\\' + fileName + '.wav'
		#get the sound file from the gateway in bytes
		soundBytes = retrieveSoundBytes(fileName)
		#write the sound file to the client file system
		writeSoundBytes(filePath, soundBytes)
		
	#update dataset tag for options dropdown
	dataset = getAlarmSoundDataset()
	system.tag.writeBlocking([tagPath], [dataset])



def playAlarmSound():
	'''
	Play the selected alarm sound  once
	
	Args:
		wavFile	(string)	:	path to the wavFile
		
	Returns:
		n/a
	'''
	path = alarmSoundPath
	
	tagsToRead = [
	
				'[client]Alarms/Alarm_Sound',						#selected alarm sound file name, no extension
				'[client]Alarms/Sound_Volume',						#volume level 0 - 100
				'[client]Alarms/AlarmSoundOptions',					#alarm sound file name options dataset
				]
	
	selection, volume, options = [tag.value for tag in system.tag.readBlocking(tagsToRead)]
	fileNames = options.getColumnAsList(1)
	#selection is valid
	if selection and selection in fileNames:
		wavFile = path + '\\' + selection + '.wav'
		system.util.playSoundClip(wavFile, volume/100.0, True)


def playAlarmSoundLoop():
	'''
	Continiously play the selected alarm sound
	'''
	path = alarmSoundPath
	
	tagsToRead = [
					'[client]Alarms/Alarm_Sound',						#selected alarm sound by file Name (does not include ".wav")
					'[client]Alarms/Sound_Volume',						#alarm volume level (0 - 100)
					'[client]Alarms/Alarm_Sound_EN',					#alarm sound enabled
					'[System]Gateway/Alarming/Active and Unacked',		#active and unacked tag count
					'[client]Alarms/AlarmSoundOptions',					#alarm sound file name options dataset
				]
				
	selection, volume, EN, count, options = [tag.value for tag in system.tag.readBlocking(tagsToRead)]
	
	#alarm sound is enabled
	if EN:
		#at least one active, unacked alarm
		if count > 0:
			fileNames = options.getColumnAsList(1)
			#selection is valid
			if selection and selection in fileNames:
				wavFile = path + '\\' + selection + '.wav'
				system.util.playSoundClip(wavFile, volume/100.0, True)
				
				playAlarmSoundLoop()
				
			else:
				return
		else:
			return
	else:
		return



##################################################################################
####### 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
	
	#filename given
	if payload['fileName']:
		#generate file path
		filePath = path + '\\' + payload['fileName'] + '.wav'
		#check to see that the filePath exists
		if system.file.fileExists(filePath):
			#return the file in byte format
			return system.file.readFileAsBytes(filePath)

client startup script:

Sound.updateClientAlarmSounds()

client tag change script AlarmHorn:

#triggers:
#[client]Alarms/Alarm_Sound_EN
#[System]Gateway/Alarming/Active and Unacked

system.util.invokeAsynchronous(Sound.playAlarmSoundLoop)

Gateway message handlers.
SendAlarmSoundList

def handleMessage(payload):
	return Sound.retrieveAlarmSoundList_MH()

SendAlarmSound

def handleMessage(payload):
	return Sound.retrieveAlarmSound_MH(payload)

I'm going to add this to my Ignition project template library. We don't use perspective much, but I'd like to organize these functions for vision accordingly.

I'm not sure how this would be implemented in perspective and which functions would be common between vision and perspective implementations. Does anybody have any insights into where each should sit in the following package heirarchy:

Sound/Common
Sound/Vision
Sound/Perspective

Your only avenue to play sounds in Perspective is the Audio Player component, which requires a hosted URL (meaning Webdev, Phil's blob server module, or a feature we're planning to add to 8.3.x). And lots of user agents (like web browsers) will outright prevent unsolicited audio from playing, full stop. None of the techniques here will really apply (even though, technically, you can call playSoundClip on the gateway, it's just almost certainly not what you want to do).

2 Likes

I kind of figured that. Everything is under Common.Sound.Vision and I have a blank Common.Sound.Perspective - I'll figure that out when/if the time comes.

I think this works, except when the sound is enabled, then all alarms are aked and a new unacked alarm shows up, it doesn't trigger the script.

I've tried adding the system tag to the client tag change script, but that hasn't produced the desired result.

I would add logging into your async script to write out values to the log to see if it's getting triggered properly, and then if it's seeing the values you're expecting to see.

It seems to work a little bit, but if the active unacked tag increases from 1 to 2 while there is an alarm already going it'll start another one. I'll have to try and check the previous value.

Trying to do that and no matter what I do I always get object 'NoneType' has no attribute 'value'

#triggers:
#[client]Alarms/Alarm_Sound_EN
#[default]tempAlarmTest

if event.getPreviousValue().value == 0:
	system.util.invokeAsynchronous(Sound.playAlarmSoundLoop)

I've tried previousValue.value, previousValue.getValue(), and event.getPreviousValue().value

I shouldn't have to check which tag changed from 0 to non-zero, the EN changing from 0 to 1 or alarm count change from 0 would be the same result.

Easiest would be to just write to a client tag called something like "AlarmSoundPlaying".
As part of your tag read in playAlarmSoundLoop you'd check if it's false (just put it in with your first if EN: and only do the rest if it's false.)
Then right before you call the playAlarmSoundLoop in your script, you'd write it to False, and write it to False right before all the other returns also.

I still feel like this is almost more complex than doing the method I suggested with just using the SoundPlayer component.

my docked nav window is already pretty complex and I didn't want to add more to it for this.

So far, this isn't too bad. If I could just figure out this null thing I think it would be done.

@PGriffith any help on that error? Why would the previous value be null?

Initial change causes this because there isn't a previous value.

One cool thing about python is that its if statements will short circuit, so you should be able to filter out nulls like this:

# If a null value is encountered, previousValue.value won't be checked
# ...this eliminates an exception that would otherwise be raised for a null value not having a .value property
if previousValue is not None and previousValue.value == 0:

At some point in 8.1, it became simpler:

if not initialChange and previousValue.value == 0:
1 Like