Skip to content

Instantly share code, notes, and snippets.

@dreness
Last active May 7, 2022 18:52
Show Gist options
  • Save dreness/e61fb16dcb831adaf6ff to your computer and use it in GitHub Desktop.
Save dreness/e61fb16dcb831adaf6ff to your computer and use it in GitHub Desktop.
Attempt to repair an interrupted QuickTime Player audio recording
#!/bin/zsh
# https://gist.github.com/dreness/e61fb16dcb831adaf6ff
# dre@mac.com
#
# This script attempts to repair Quicktime Player audio recordings
# that become broken when Quicktime Player is interrupted while recording.
# AIFC files can be recovered with no additional help, however to recover
# M4A files, you also need the 'faad' command line tool.
# The easiest way to get that is:
# 1. Install Homebrew: https://brew.sh
# 2. Run the terminal command: brew install faad2
#
# Instructions for people familiar with a unix shell:
# * Run this shell script, passing the path to a broken recording as
# the only command line argument.
#
# Instructions for everyone else:
# Inside the zip file you can get from this gist's page (URL at the top),
# you'll find another file called RunMe.command.
# * Hold the control key, click RunMe.command, then choose "Open".
# You will be warned:
# macOS cannot verify the developer of "RunMe.command".
# Are you sure you want to open it?
# This warning is expected (1).
# * Click "Open".
# * A new Terminal window appears, inviting you to drag your broken file into
# the terminal window, then press return. Do that. Good luck!
#
# (1) Why is this warning expected? How could you grandstand at a time like this?
# The warning is expected for two reasons:
# * I choose not to jump through the hoops that would satisfy macOS that this
# script came from a reputable source and has not been tampered with. Think
# about it like this: the commercial food industry needs oversight and regulation
# for a lot of very good reasons, however not all food is commercial food. If
# somebody tried to tell me I was only allowed to eat FDA-certified food,
# regardless of the source, I would respond with my own colorful suggestion
# of what that person should eat. macOS doesn't *quite* go as far as telling you
# you're only allowed to run verified software. Instead, macOS merely hides
# the option for running unverified software, which is why you have to choose
# Open from the contextual menu instead of double-clicking RunMe.command like a
# regular person. This would be like if your local farmer's market was moved from
# the town square to an undisclosed location with no pedestrian access. It's
# still perfectly legal for you to buy food there, but first you have to
# a) know that the market exists, which is easy for you because you still remember -
# but what about the childrens!
# b) find the undisclosed location
# Just like I think people should be allowed to decide what they eat, I think people
# should also be allowed to decide what software they run. By posting software that
# is intentionally NOT pre-authorized, I am requiring the tiny handful of people
# who desperately hope to use this solution to exercise their ability to run
# unverified apps. In other words, this *is* a trojan horse, but the payload is
# this heartfelt plea instead of malware.
# * This verification system (called 'notarization') doesn't support shell scripts
# anyway, so I couldn't avoid this warning without rewriting it in a different form,
# which would be significantly more work.
#
# Look for partial files with:
# find ~/Library/Containers/com.apple.QuickTimePlayerX -name "*.m4a" -o -name "*.aifc"
#
# Depending on recording settings, these files are either AIFC or M4A format.
# AIFC Reference:
# http://muratnkonar.com/aiff/index.html
# QTP recording settings:
# https://support.apple.com/guide/quicktime-player/record-audio-qtpf25d6f827/10.5/mac/11.0
#
# NOTE! This script edits AIFC files in place because these edits are very surgical, and
# also because these files might be somewhat large, so avoiding extra copies is good.
# M4A files are not edited in place; instead a temporary directory is created to hold the fixed files.
# That directory will be opened automatically.
# set -e
# set -x
set -o pipefail
# AIFC stuff - calculated values
unset s # file size
unset form # FORM offset
unset ssnd # SSND offset
unset a_sz # AIFC data size
unset s_sz # SSND data size
# M4A stuff
# Starting offset and leading signature for mono AAC audio
aac_1ch_ofs=0xF000
aac_1ch_tip=(00d04007 00d00007)
# Starting offset and leading signature for stereo AAC audio
aac_2ch_ofs=0xE000
aac_2ch_tip=(21000340 21200340)
# Attempt to recover an AIFC file
recoverAIFC() {
# Examine this many bytes in the file for AIFC headers to fix
peek=10000
# get file size
s=$(stat -f "%z" ${file})
checkNull "get file size" ${s}
#### FORM chunk
#
# get start of FORM chunk
form=$(xxd -l ${peek} -c 4 ${file} | grep "FORM")
checkFail "find FORM offset" $?
form=$(echo $form | awk -F: '{print $1}')
checkFail "capture the FORM offset" $?
form=$(printf "%d" \0x${form})
checkNull "convert FORM offset to decimal" ${form}
# offset by 4 (size of "FORM")
form=$(( $form + 4 ))
checkNull "find end of FORM header" ${form}
# calculate size of AIFC data chunk
a_sz=$(( $s - $form ))
checkNull "calculate AIFC data size" ${a_sz}
# convert the AIFC byte size from decimal to hex
b=$(printf "%08x" $a_sz) # aabbccdd
checkNull "convert AIFC size to hex" $b
# prepend each hex digit with '\x'
c=''
for (( i=0; i<${#b}; i=i+2 )); do
c=${c}$(echo -en \\x${b:$i:2})
done
# write the calculated FORM size
echo -ne "$c" | \
dd conv=notrunc of=${file} bs=1 seek=${form} count=4 &> /dev/null
checkFail "write new AIFC size header" $?
#### SSND chunk
#
# find start of SSND chunk
ssnd=$(xxd -l ${peek} -c 4 ${file} | grep "SSND")
checkFail "find SSND offset" $?
ssnd=$(echo $ssnd | awk -F: '{print $1}')
checkFail "capture the SSND offset" $?
ssnd=$(printf "%d" \0x${ssnd})
checkNull "convert SSND offset to decimal" ${ssnd}
# offset by 4 (size of "SSND")
ssnd=$(( $ssnd + 4 ))
checkNull "find end of SSND header" ${ssnd}
# calculate size of SSND data chunk
s_sz=$(( $s - $ssnd ))
checkNull "calculate SSND data size" ${s_sz}
# convert the AIFC byte size from decimal to hex
b=$(printf "%08x" $s_sz)
checkNull "convert SSND size to hex" $b
# prepend each hex digit with '\x'
c=''
for (( i=0; i<${#b}; i=i+2 )); do
c=${c}$(echo -en \\x${b:$i:2})
done
# write the calculated SSND size
echo -ne "$c" | \
dd conv=notrunc of=${file} bs=1 seek=${ssnd} count=4 &> /dev/null
checkFail "write new SSND size header" $?
echo '\nAll done!'
}
# Attempt to recover an M4A file
recoverM4A() {
FOUND=''
# Look for audio data at a known offset with expected start bytes
findAAC() {
# $1: offset bytes in hex
OFS=$1
shift
# Everything else is taken as array elements of starting bytes
CHOICES=("$@")
for x in $CHOICES
do
BYTES=$(xxd -s $OFS -g 4 -l 4 ${file} | awk '{print $2}')
checkFail "look at $OFS for $x" $?
if [[ ${BYTES} = ${x} ]]
then
FOUND=${OFS}
checkNull "found AAC audio" ${FOUND}
break
fi
done
}
findAAC ${aac_1ch_ofs} "${aac_1ch_tip[@]}"
checkFail "Look for mono AAC audio" $?
# no mono AAC audio; try stereo?
if [[ -z ${FOUND} ]]
then
findAAC ${aac_2ch_ofs} "${aac_2ch_tip[@]}"
checkFail "Look for stereo AAC audio" $?
fi
if [[ -z ${FOUND} ]]
then
dlog "Unable to locate AAC audio :/"
exit 1
fi
TMPDIR=$(mktemp -d)
checkNull "create temp directory" ${TMPDIR}
M4A_TEMP=${TMPDIR}/headless.m4a
dd conv=noerror if=${file} of=${M4A_TEMP} bs=1 skip=${FOUND} &> /dev/null
checkFail "create 'headless' m4a file" $?
WAVF=${TMPDIR}/fixed.wav
faad -o ${WAVF} ${M4A_TEMP} &> /dev/null
checkFail "decode 'headless' m4a to WAV" $?
ffmpeg -i ${WAVF} ${TMPDIR}/fixed.m4a &> /dev/null
checkFail "reencode WAV to m4a" $?
dlog "All done!"
open $TMPDIR
exit 0
}
# Check command line input
if [ -z $1 ] ; then
echo 'Supply the path of a broken AIFC or M4A recording as the only argument.'
exit 1
else
if [ ! -f $1 ] ; then
echo "Can't find file $1!"
exit 1
fi
file=${1}
fi
# Error handling
checkNull() {
if [[ $# -ne 2 ]] ; then
echo "Incorrect number of args!"
exit 1
fi
if [ -z ${1} ] ; then
echo "Function description missing!"
exit 1
fi
if [ -z ${2} ] ; then
echo "Failed to ${1} - bailing."
exit 1
else
dlog ${1} ${2}
fi
}
checkFail() {
if [[ $# -ne 2 ]] ; then
echo "Incorrect number of args!"
exit 1
fi
if [ -z ${1} ] ; then
echo "Function description required!"
exit 1
fi
if [[ ${2} -ne 0 ]] ; then
echo "Error! Could not ${1}!"
exit 1
else
dlog ${1} "... done"
fi
}
dlog() {
printf "%36s : %s \n" ${1} ${2}
}
#### Infer file type from extension
F_EXT=${file##*.}
checkNull "get file type" ${F_EXT}
if [[ $F_EXT = "m4a" ]]
then
recoverM4A
elif [[ $F_EXT = "aifc" ]]
then
recoverAIFC
else
echo "This script supports only M4A or AIFC files, which this file isn't."
exit 1
fi
<?xml version="1.0" encoding="UTF-8"?>
<ufwb version="1.14">
<grammar name="AIFC grammar" start="id:530" author="Andre LaBranche" uti="public.aifc-audio">
<description>Grammar for AIFC files</description>
<structure name="AIFC file" id="530" length="Size + 4" alignment="0" encoding="ISO_8859-1:1987" endian="big" signed="no" valueexpression="Size">
<string name="Type ID" mustmatch="yes" id="531" fillcolor="FFD8B2" type="fixed-length" length="4" encoding="ISO_8859-1:1987">
<fixedvalues>
<fixedvalue name="TypeID" value="FORM"/>
</fixedvalues>
</string>
<number name="Size" id="532" fillcolor="D8D8FE" type="integer" length="4"/>
<string name="Form Type" mustmatch="yes" id="533" fillcolor="FFB2FE" type="fixed-length" length="4">
<fixedvalues>
<fixedvalue name="Form Type" value="AIFC"/>
<fixedvalue name="Form Type" value="AIFF"/>
</fixedvalues>
</string>
<structure name="AIFC Version" id="534" length="0" alignment="2" repeatmin="0" valueexpression="timeStamp">
<string name="Type ID" mustmatch="yes" id="535" fillcolor="FFD8B2" type="fixed-length" length="4">
<fixedvalues>
<fixedvalue name="Version" value="FVER"/>
</fixedvalues>
</string>
<number name="Size" mustmatch="yes" id="536" fillcolor="D8D8FE" type="integer" length="4">
<fixedvalues>
<fixedvalue name="Size" value="4"/>
</fixedvalues>
</number>
<number name="timeStamp" mustmatch="yes" id="537" fillcolor="FFB2FE" type="integer" length="4">
<fixedvalues>
<fixedvalue name="Version" value="2726318400"/>
</fixedvalues>
</number>
</structure>
<structure name="Common" id="539" length="Size + 8" alignment="2" valueexpression="Desc">
<string name="Type ID" mustmatch="yes" id="540" fillcolor="FFD8B2" type="fixed-length" length="4">
<fixedvalues>
<fixedvalue name="Type ID" value="COMM"/>
</fixedvalues>
</string>
<number name="Size" id="541" fillcolor="D8D8FE" type="integer" length="4"/>
<number name="NumChannels" id="542" fillcolor="B2FDFF" type="integer" length="2"/>
<number name="numSampleFrames" id="543" fillcolor="FFFAB2" type="integer" length="4"/>
<number name="sampleSize" id="544" fillcolor="D8D8FE" type="integer" length="2"/>
<structure name="SampleRate" id="545" length="10" alignment="0" repeatmin="0" valueexpression="Rate">
<number name="Rate" id="546" fillcolor="FFE4B2" type="float" length="64" lengthunit="bit"/>
</structure>
<string name="Compression" id="548" fillcolor="F3FFB2" repeatmin="0" type="fixed-length" length="4"/>
<string name="Desc" id="549" fillcolor="B2D4FF" repeatmin="0" type="pascal" length="actual"/>
</structure>
<structure name="Chan" id="551" alignment="0" repeatmin="0" fillcolor="D5D5D5">
<string name="Type ID" mustmatch="yes" id="552" fillcolor="FFD8B2" type="fixed-length" length="4">
<fixedvalues>
<fixedvalue name="Type ID" value="CHAN"/>
</fixedvalues>
</string>
<number name="Size" id="553" fillcolor="D8D8FE" type="integer" length="4"/>
<structure name="ChanData" id="554" length="Size" alignment="2"/>
</structure>
<structure name="Filler" id="557" length="0" alignment="0" repeatmin="0" fillcolor="E6E6E6" valueexpression="Size">
<string name="Type ID" mustmatch="yes" id="558" type="fixed-length" length="4">
<fixedvalues>
<fixedvalue name="Type ID" value="FLLR"/>
</fixedvalues>
</string>
<number name="Size" id="559" type="integer" length="4"/>
<structure name="Filler" id="560" length="Size" alignment="0" valueexpression="Size"/>
</structure>
<structure name="Extra" id="563" length="Size + 8" alignment="2" repeatmin="0" repeatmax="-1">
<string name="Type ID" mustmatch="yes" id="564" fillcolor="FFD8B2" type="fixed-length" length="4">
<fixedvalues>
<fixedvalue name="Type" value="AUTH"/>
<fixedvalue name="Type" value="NAME"/>
<fixedvalue name="Type" value="ANNO"/>
</fixedvalues>
</string>
<number name="Size" id="565" fillcolor="D8D8FE" type="integer" length="4"/>
<string name="Value" id="566" fillcolor="FFB2FE" type="fixed-length" length="Size"/>
</structure>
<structure name="SSND" id="568" length="Size - 8" alignment="2" repeatmin="0" repeatmax="-1" valueexpression="Size">
<string name="Type ID" mustmatch="yes" id="569" fillcolor="FFD8B2" type="fixed-length" length="4">
<fixedvalues>
<fixedvalue name="Type ID" value="SSND"/>
</fixedvalues>
</string>
<number name="Size" id="570" fillcolor="D8D8FE" type="integer" length="4"/>
<number name="Offset" id="571" fillcolor="FFB2FE" type="integer" length="4"/>
<number name="blockSize" id="572" fillcolor="D8D8FE" type="integer" length="4"/>
<structure name="Sound Data" id="573" length="prev.Size - 4" alignment="0" fillcolor="D8F9C7" valueexpression="Size"/>
</structure>
</structure>
</grammar>
</ufwb>
#!/bin/zsh
# This helper script assumes it lives in the same folder as fix-aifc.sh,
# which will be the case if you click the "Download ZIP" button on the gist page:
# https://gist.github.com/dreness/e61fb16dcb831adaf6ff
#
# To use this:
# 1. Open the Terminal application, in /Applications/Utilities
# 2. In Finder, drag this file into the Terminal window, then press return.
# 3. You will be prompted to drag in the broken recording and press return.
echo "Drag your broken recording into this window, then press return."
read F
echo Running: fix-aifc.sh ${F}
fix-aifc.sh ${F}
echo Press return to exit
read
@dreness
Copy link
Author

dreness commented Jul 27, 2020

I noticed from your work that the "form" figure = total bytes - 8; and that "sound" figure = total bytes - 4088. It worked :)

Using the above formula probably would work for most or all aborted recordings because:

  • The AIFC file format has been around for a long time and is stable
  • There's probably not a very wide range of different (user) settings that might make the recording different enough that the 'hard coded' logic above no longer works.

I Just wanted to make sure you (and any future readers) are aware that the most correct way to fix a broken recording involves calculating the two different values that are incorrect in an aborted recording. Even if the AIFC file format or Quicktime Player doesn't change, there are still other changes that could break that logic - for example, a change in the filesystem type or CPU architecture that might come with a newer Mac. Depending on how old your Mac is, it's possible that you might experience both kinds of changes if your next Mac has an Apple CPU instead of Intel (those machines haven't started shipping yet, but soon...)

I completely understand that in the quest to save your data, you were perhaps not so interested in the conceptual aspects of how file formats work (which I thought was the main value of the video, but clearly I was wrong about that :) If your recording really is 750 MB, that's huge, and I can understand the stress at the thought of losing it. Don't forget that if you want extra practice at rescuing broken recordings, just do what I did in the video - start recording then force quit Quicktime Player.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment