Quick tour of SOFA and sofar

If you are new to SOFA and/or sofar, this is a good place to start. SOFA is short for Spatially Oriented Format for Acoustics and is an open file format for saving acoustic data, for example head-related impulse responses (HRIRs). Good places to get more information about SOFA after this introduction are

Creating SOFA objects

To cover a variety of data, SOFA offers different conventions. A convention defines, what data can be saved and how it is saved. You should always find the most specific convention for your data. This will help you to identify relevant data and meta data that you should provide along the actual acoustic data. Using sofar, a list of possible conventions can be obtained with

[1]:
import sofar as sf
import os

sf.list_conventions()
Available SOFA conventions:
GeneralTF (Version 1.0)
SingleRoomSRIR (Version 1.0)
FreeFieldHRIR (Version 1.0)
SimpleHeadphoneIR (Version 1.0)
SimpleFreeFieldHRIR (Version 1.0)
AnnotatedReceiverAudio (Version 0.2)
GeneralFIR-E (Version 2.0)
AnnotatedEmitterAudio (Version 0.2)
SingleRoomMIMOSRIR (Version 1.0)
FreeFieldHRTF (Version 1.0)
GeneralTF (Version 2.0)
FreeFieldDirectivityTF (Version 1.1)
SimpleFreeFieldHRSOS (Version 1.0)
SimpleFreeFieldHRTF (Version 1.0)
GeneralSOS (Version 1.0)
SimpleFreeFieldSOS (Version 1.0)
GeneralFIR (Version 1.0)
GeneralTF-E (Version 1.0)
SimpleFreeFieldTF (Version 1.0)
FreeFieldDirectivityTF (Version 1.0)
SimpleHeadphoneIR (Version 0.1)
AnnotatedEmitterAudio (Version 0.1)
SingleRoomDRIR (Version 0.2)
GeneralFIRE (Version 1.0)
SingleTrackedAudio (Version 0.2)
SimpleHeadphoneIR (Version 0.2)
AnnotatedReceiverAudio (Version 0.1)
SimpleFreeFieldTF (Version 0.4)
MultiSpeakerBRIR (Version 0.3)
SingleRoomDRIR (Version 0.3)
SingleTrackedAudio (Version 0.1)
SimpleFreeFieldHRIR (Version 0.4)

Let us assume, that you want to store head-related impulse responses (HRIRs). In this case the most specific convention is SimpleFreeFieldHRIR. To create a SOFA object use

[2]:
sofa = sf.Sofa('SimpleFreeFieldHRIR')
print(sofa)
sofar.SOFA object: SimpleFreeFieldHRIR 1.0

The return value sofa is a sofar.Sofa object filled with the default values of the SimpleFreeFieldHRIR convention. Note that you can also get a sofa object that has only the mandatory attributes by calling sf.Sofa('SimpleFreeFieldHRIR', mandatory=True). However, it is recommended to start with all attributes and discard empty optional attributes before saving the data.

Getting information about SOFA objects

An formal description of each convention is given in the sofar documentation. You might have noted from the documentation that three different kinds of data types can be stored in SOFA files:

  • Attributes: Attributes are meta data stored as strings. There are two kinds of attributes. Global attributes give information about the entire data stored in a SOFA file. All entires starting with GLOBAL are such attributes. Specific attributes hold meta data for a certain variable. These attributes thus start with the name of the variable followed by an underscore, e.g., ListenerPosition_Units. An exception to this rule are the data variables, e.g, Data_IR is not an attribute but a double variable.

  • Double Variables: Variables of type double store numeric data and can be entered as numbers, lists, or numpy arrays.

  • String Variables: Variables of type string store strings and can be entered as strings, lists of string, or numpy string arrays.

The data can be mandatory, optional, and read only and must have a shape (dimension in SOFA language) according to the underlying convention. Read on for more information.

To get a quick insight into SOFA objects

  • sofa.inspect prints the data stored in a SOFA object or at least gives the shape in case of large arrays that would clutter the output. This is helpful when reading data from an existing SOFA object.

  • sofa.list_dimensions prints the dimensions of the data inside the SOFA object.

  • sofa.get_dimension returns the size of a specific dimension.

For the SimpleFreeFieldHRIR SOFA object we have the following dimensions

[3]:
sofa.list_dimensions
R = 2 receiver (set by ReceiverPosition of dimension RCI, RCM)
E = 1 emitter (set by EmitterPosition of dimension ECI, ECM)
M = 1 measurements (set by Data_IR of dimension MRN)
N = 1 samples (set by Data_IR of dimension MRN)
C = 3 coordinate dimensions, fixed
I = 1 single dimension, fixed
S = 0 maximum string length

In this case, M denotes the number of source positions for which HRIRs are available, R is the number of ears - which is two - and N gives the lengths of the HRIRs in samples. S is zero, because the convention does not have any string variables. C is always three, because coordinates are either given by x, y, and z values or by their azimuth, elevation and radius in degree.

It is important to be aware of the dimensions and enter data as determined by the convention. SOFA sets the dimensions implicitly. This means the dimensions are derived from the data itself, as indicated by the output of sofa.list_dimensions above (set by…). In some cases, variables can have different shapes. An example for this is the ReceiverPosition which can be of shape RCI or RCM. To get a dimension as a variable use

[4]:
sofa.get_dimension('N')
[4]:
1

Let’s assume you want to have a better overview of the data contained in the Sofa object. For this purpose you can use sofa.inspect. This lists the information contained in the SOFA attributes, and gives at least the shapes of all SOFA variables. In case a variable contains only a few entries, its values will be shown as well. This will most likely give you a better idea of the data then looking at the definition of the convention or calling sofa.list_dimensions.

[5]:
sofa.inspect()
SimpleFreeFieldHRIR 1.0 (SOFA version 2.1)
-------------------------------------------
GLOBAL_Conventions : SOFA
GLOBAL_Version : 2.1
GLOBAL_SOFAConventions : SimpleFreeFieldHRIR
GLOBAL_SOFAConventionsVersion : 1.0
GLOBAL_APIName : sofar SOFA API for Python (pyfar.org)
GLOBAL_APIVersion : sofar v1.1.1 implementing SOFA standard AES69-2022 (SOFA conventions 2.1)
GLOBAL_ApplicationName : Python
GLOBAL_ApplicationVersion : 3.10.13 [CPython - GCC 11.3.0]
GLOBAL_AuthorContact :
GLOBAL_Comment :
GLOBAL_DataType : FIR
GLOBAL_History :
GLOBAL_License : No license provided, ask the author for permission
GLOBAL_Organization :
GLOBAL_References :
GLOBAL_RoomType : free field
GLOBAL_Origin :
GLOBAL_DateCreated : 2023-12-22 11:51:54
GLOBAL_DateModified : 2023-12-22 11:51:54
GLOBAL_Title :
GLOBAL_DatabaseName :
GLOBAL_ListenerShortName :
ListenerPosition : (I=1, C=3)
  [0 0 0]
ListenerPosition_Type : cartesian
ListenerPosition_Units : metre
ReceiverPosition : (R=2, C=3, I=1)
  [[ 0.    0.09  0.  ]
   [ 0.   -0.09  0.  ]]
ReceiverPosition_Type : cartesian
ReceiverPosition_Units : metre
SourcePosition : (I=1, C=3)
  [0 0 1]
SourcePosition_Type : spherical
SourcePosition_Units : degree, degree, metre
EmitterPosition : (E=1, C=3, I=1)
  [0 0 0]
EmitterPosition_Type : cartesian
EmitterPosition_Units : metre
ListenerUp : (I=1, C=3)
  [0 0 1]
ListenerView : (I=1, C=3)
  [1 0 0]
ListenerView_Type : cartesian
ListenerView_Units : metre
SourceUp : (I=1, C=3)
  [0 0 1]
SourceView : (I=1, C=3)
  [1 0 0]
SourceView_Type : cartesian
SourceView_Units : metre
Data_IR : (M=1, R=2, N=1)
  [0 0]
Data_SamplingRate : 48000
Data_SamplingRate_Units : hertz
Data_Delay : (I=1, R=2)
  [0 0]

Interacting with data to SOFA objects

Data can be obtained by simply calling attributes of the Sofa object, e.g.

[6]:
sofa.Data_IR
[6]:
array([[[0],
        [0]]])

Note that all variables are stored as numpy arrays, which means that you can use indexing, and slicing just like with any other numpy array. For example you can get only the left ear data with

[7]:
sofa.Data_IR[:, 0]
[7]:
array([[0]])

Data can be added in the same way. The HRIRs and source position can for example be set with

[8]:
sofa.Data_IR = [[[1, 0, 0], [0, 0.5, 0]]]
sofa.SourcePosition = [90, 0, 1.5]

Now, the SOFA object contains a single HRIR - which is [1, 0, 0] for the left ear and [0, 0.5, 0] for the right ear - for a source at 90 degree azimuth, 0 degree elevation and a radius of 1.5 meter. Note that we entered lists and that sofar automatically converts the lists to numpy arrays. Sofar handles this in two steps:

  1. When entering data as lists it is converted to a numpy array with at least two dimensions.

  2. Missing dimensions are appended when writing the SOFA object to disk.

Normally, you would continue with filling all mandatory entries of the SOFA object. For this example we’ll stop here for the sake of brevity. Let us, however, delete an optional entry that we do not need at this point

[9]:
sofa.delete('SourceUp')

In some cases you might want to add custom data. Although third party applications most likely won’t make use of non-standardized data this can be useful for documentation and research use. Try this to add a temperature value and unit. Note that you have to specify the data type and shape (dimensions) if you are adding SOFA variables

[10]:
sofa.add_variable('Temperature', 25.1, 'double', 'MI')
sofa.add_attribute('Temperature_Units', 'degree celsius')

After entering the data, the SOFA object should be verified to make sure that your data agrees with the SOFA standard and that if it can be read by other applications.

[11]:
sofa.verify()

This will check specific rules determined by the SOFA standard AES69 and general rules such as:

  • Are all mandatory data contained?

  • Are the names of variables and attributes in accordance with the SOFA standard?

  • Are the data types in accordance with the SOFA standard?

  • Are the dimensions of the variables consistent and in accordance to the SOFA standard?

  • Are the values of attributes consistent and in accordance to the SOFA standard?

If any violations are detected, an error is raised.

Reading and writing SOFA objects

Note that you usually do not need to call sofa.verify() separately because it is by default called if you create, write, or read a SOFA object. To write your SOFA object to disk type

[12]:
sf.write_sofa(os.path.join('data', 'my_first.sofa'), sofa)

It is good to know that SOFA files are essentially netCDF4 files which is based on HDF5. They can thus be viewed with HDF View.

To read your sofa file you can use

[13]:
sofa_read = sf.read_sofa(os.path.join('data', 'my_first.sofa'))
SOFA file contained custom entries
----------------------------------
Temperature, Temperature_Units

And to see that the written and read files contain the same data you can check

[14]:
sf.equals(sofa, sofa_read)
[14]:
True

Upgrading SOFA files

SOFA conventions might get updates to fix bugs in the conventions, in case new conventions are introduced, or in case conventions get deprecated. To find out if SOFA file is up to date, call upgrade_convention to get a list of upgrade choices or the information that the data is up to date.

[15]:
sofa.upgrade_convention()
Convention SimpleFreeFieldHRIR v1.0 is up to date

Next steps

For detailed information about sofar and more examples please refer to the sofar documentation.