#
HLA Files (Android)
#
Concept
HLA (Hapticlabs Android) files are used to express haptic signals in a format that corresponds to Androids VibrationEffect.createWaveform
.
In addition, HLA files define which audio signals are played corresponding to the haptic signals, if any.
Furthermore, these files contain metadata regarding the haptic signal, such as its title and duration.
#
Structure
HLA files are JSON files that contain the following fields:
ProjectName
[string
]: The title of the haptic project.TrackName
[string
]: The title of the haptic track.Duration
[number
]: The duration of the haptic signal in milliseconds.Timings
[array<number>
]: An array of integers that define the timing of the haptic signal as required byVibrationEffect.createWaveform
.Amplitudes
[array<number>
]: An array of integers, range 0-255, that define the amplitude of the haptic signal as required byVibrationEffect.createWaveform
.Repeat
[number
]: The number of times the haptic signal is repeated or -1 to indicate no repetition, as required byVibrationEffect.createWaveform
.RequiredAudioFiles
[array<string>
]: An array of strings that define the audio files that are referenced by this HLA file.Audios
[array<AudioFile>
]: An array of objects that define the audio files that are referenced by this HLA file:AudioFile
[{Filename: string, Time: number}
]: An object that defines an audio file and the timestamp when it is played, relative to the start of the signal.Filename
[string
]: The relative path (from the .hla location) to the audio file.Time
[number
]: The timestamp in milliseconds when the audio file is played.
#
Playback
#
React Native
The react-native-hapticlabs
npm package provides a simple way to play back HLA files on Android devices:
import { playHLA } from 'react-native-hapticlabs';
// Play an OGG file with haptic feedback
playHLA('path/to/file.hla');
#
Kotlin
To play back haptic signals encoded in HLA files using Kotlin, you can use the following code snippet (see the implementation for the react-native package):
val data: String
try {
val file = File("path/to/file.hla")
val fis = FileInputStream(file)
val dataBytes = ByteArray(file.length().toInt())
fis.read(dataBytes)
fis.close()
data = String(dataBytes, StandardCharsets.UTF_8)
} catch (e: IOException) {
e.printStackTrace()
promise.reject("Error reading file", e)
return
}
// Parse the file to a JSON
val gson = Gson()
val jsonObject = gson.fromJson(data, JsonObject::class.java)
// Extracting Amplitudes array
val amplitudesArray = jsonObject.getAsJsonArray("Amplitudes")
val amplitudes = IntArray(amplitudesArray.size())
for (i in 0 until amplitudesArray.size()) {
amplitudes[i] = amplitudesArray[i].asInt
}
// Extracting Repeat value
val repeat = jsonObject.get("Repeat").asInt
// Extracting Timings array
val timingsArray = jsonObject.getAsJsonArray("Timings")
val timings = LongArray(timingsArray.size())
for (i in 0 until timingsArray.size()) {
timings[i] = timingsArray[i].asLong
}
val durationMs = jsonObject.get("Duration").asLong
val audiosArray = jsonObject.getAsJsonArray("Audios")
// Prepare the vibration
val vibrationEffect = VibrationEffect.createWaveform(timings, amplitudes, repeat)
val vibratorManager = reactContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
val vibrator = vibratorManager.getDefaultVibrator()
// [Optional] Prepare the audio
// Execute the vibration
vibrator.vibrate(vibrationEffect)
Playing audio files in sync with the haptic output of Vibrator.vibrate
is not straightforward on Android. We found that AudioTrack
provided the best results for synchronizing audio and haptic signals because the audio signal can be preloaded and subsequently played back with relatively low (however still nonzero) latency. This can be implemented as follows (see the implementation for the react-native package):
/* A class to handle the audio playback */
class AudioTrackPlayer(private val filePath: String, private val reactContext: ReactApplicationContext) {
private var audioTrack: AudioTrack? = null
private var extractor: MediaExtractor? = null
private var codec: MediaCodec? = null
private var inputBuffers: Array<ByteBuffer>? = null
private var outputBuffers: Array<ByteBuffer>? = null
private var info: MediaCodec.BufferInfo? = null
private var isEOS = false
/**
* Preload the audio data from the file. This sets up the MediaExtractor and
* MediaCodec and prepares the AudioTrack for playback.
*/
fun preload() {
extractor = MediaExtractor()
try {
extractor?.setDataSource(filePath)
var format: MediaFormat? = null
// Find the first audio track in the file
for (i in 0 until extractor!!.trackCount) {
format = extractor!!.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME)
if (mime?.startsWith("audio/") == true) {
extractor!!.selectTrack(i)
codec = MediaCodec.createDecoderByType(mime)
codec?.configure(format, null, null, 0)
break
}
}
if (codec == null) {
return // No suitable codec found
}
codec?.start()
info = MediaCodec.BufferInfo()
// Set up AudioTrack
val sampleRate = format!!.getInteger(MediaFormat.KEY_SAMPLE_RATE)
val channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
val channelConfig = if (channelCount == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO
val audioFormat = AudioFormat.ENCODING_PCM_16BIT
// Load the entire audio file into the AudioTrack
val byteArrayOutputStream = ByteArrayOutputStream()
while (!isEOS) {
if (!isEOS) {
val inIndex = codec!!.dequeueInputBuffer(10000)
if (inIndex >= 0) {
val buffer = codec!!.getInputBuffer(inIndex)
if (buffer != null) {
val sampleSize = extractor!!.readSampleData(buffer, 0)
if (sampleSize < 0) {
codec!!.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
isEOS = true
} else {
codec!!.queueInputBuffer(inIndex, 0, sampleSize, extractor!!.sampleTime, 0)
extractor!!.advance()
}
}
}
}
val outIndex = codec!!.dequeueOutputBuffer(info!!, 10000)
when (outIndex) {
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
}
MediaCodec.INFO_TRY_AGAIN_LATER -> {}
else -> {
val outBuffer = codec!!.getOutputBuffer(outIndex)
val chunk = ByteArray(info!!.size)
if (outBuffer != null){
outBuffer.get(chunk)
outBuffer.clear()
// Copy the chunk into the full buffer
try {
byteArrayOutputStream.write(chunk)
} catch (e: IOException) {
e.printStackTrace()
}
codec!!.releaseOutputBuffer(outIndex, false)
}
}
}
if (info!!.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
break
}
}
codec!!.stop()
codec!!.release()
extractor!!.release()
val fullBuffer = byteArrayOutputStream.toByteArray()
audioTrack = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(channelConfig)
.setEncoding(audioFormat)
.build()
)
.setBufferSizeInBytes(fullBuffer.size)
.setTransferMode(AudioTrack.MODE_STATIC)
.build()
audioTrack?.write(fullBuffer, 0, fullBuffer.size)
} catch (e: IOException) {
e.printStackTrace()
}
}
/**
* Internal method to handle audio playback. This method should be run in a
* separate thread.
*/
fun playAudio() {
audioTrack?.play()
}
}
// Plays the .hla file at the specified path. Make sure the file is not compressed and its associated audio files are in the same directory.
fun playHLA(path: String, handler: Handler) {
val data: String
// Load the HLA file
try {
val file = File(path)
val fis = FileInputStream(file)
val dataBytes = ByteArray(file.length().toInt())
fis.read(dataBytes)
fis.close()
data = String(dataBytes, StandardCharsets.UTF_8)
} catch (e: IOException) {
e.printStackTrace()
promise.reject("Error reading file", e)
return
}
// Parse the file to a JSON
val gson = Gson()
val jsonObject = gson.fromJson(data, JsonObject::class.java)
// Extracting Amplitudes array
val amplitudesArray = jsonObject.getAsJsonArray("Amplitudes")
val amplitudes = IntArray(amplitudesArray.size())
for (i in 0 until amplitudesArray.size()) {
amplitudes[i] = amplitudesArray[i].asInt
}
// Extracting Repeat value
val repeat = jsonObject.get("Repeat").asInt
// Extracting Timings array
val timingsArray = jsonObject.getAsJsonArray("Timings")
val timings = LongArray(timingsArray.size())
for (i in 0 until timingsArray.size()) {
timings[i] = timingsArray[i].asLong
}
val durationMs = jsonObject.get("Duration").asLong
val audiosArray = jsonObject.getAsJsonArray("Audios")
// Prepare the vibration
val vibrationEffect = VibrationEffect.createWaveform(timings, amplitudes, repeat)
val vibratorManager = reactContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
val vibrator = vibratorManager.getDefaultVibrator()
// Prepare containers for the audio data
val audioTrackPlayers = Array(audiosArray.size()) { AudioTrackPlayer("", reactContext) }
val audioDelays = IntArray(audiosArray.size())
// Get the directory of the hla file
val audioDirectoryPath = path.substringBeforeLast('/')
// Prepare the audio files
for (i in 0 until audiosArray.size()) {
val audioObject = audiosArray[i].asJsonObject
// Get the "Time" value
val time = audioObject.get("Time").asInt
// Get the "Filename" value
val fileName = audioDirectoryPath + "/" + audioObject.get("Filename").asString
// Create an AudioTrackPlayer for each audio file
val audioTrackPlayer = AudioTrackPlayer(fileName, reactContext)
// Preload the audio file
audioTrackPlayer.preload()
// Store the AudioTrackPlayer and its corresponding delay
audioTrackPlayers[i] = audioTrackPlayer
audioDelays[i] = time
}
val startTime = SystemClock.uptimeMillis()
// Schedule the audio playback
for (i in 0 until audiosArray.size()) {
handler?.postAtTime({
audioTrackPlayers[i].playAudio()
}, startTime + audioDelays[i])
}
// Execute the vibration
vibrator.vibrate(vibrationEffect)
}
Note: The haptic and audio signals are handled separately when using
Vibrator.vibrate
. Perfect synchronization can not be guaranteed due to the unpredictable latency of the audio playback. We strongly recommend using OGG files for synchronised haptics and audio playback if your target devices have high haptic capabilities as that approach handles haptic and audio signals as one signal and thus provides the best synchronization.