-
-
Save dreness/e61fb16dcb831adaf6ff to your computer and use it in GitHub Desktop.
#!/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 | |
Just commented on your YouTube link. You're amazing. Can't thank you enough. It worked in a very weird way. I'm basically stupid. I don't understand any of the codes above, but I made my own stupid calculations based on your demo on YouTube. It worked. I managed to retrieve my 20 minute precious recording. Thank you thank you thank you thank you
Yesss! Thanks for the feedback, also. It has helped me realize I need to add some additional instructions for people who aren't command-line nerds. The fact that you stumbled your way through it without running the script and still ended up saving your recording makes both of us look good :)
I tried a zillion ways on Youtube. I gave up. I don't remember how I stumble on your link! I'm just thrilled it worked. Well, I followed your steps and because it would take forever to select over 750,000,000 bytes, I noticed from your work that the "form" figure = total bytes - 8; and that "sound" figure = total bytes - 4088. It worked :) Of course, I first downloaded this Hex Fiend which I never knew existed ... So, THANK YOU
Ok, I tried to make it a little easier to run the script
- Click the "Download ZIP" button at the top right of this page to download a folder containing
RunMe.command
andfix-aifc.sh
.- Open the Terminal application, in /Applications/Utilities
- In Finder, drag
RunMe.command
into the Terminal window, then press return.- You will be prompted to drag in the broken recording and press return.
This is brilliant. I'll save it for the next time I lose my file :) Thank you Andre. Really. I'm still dancing around myself like a fool :)
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.
Ok, I tried to make it a little easier to run the script
RunMe.command
andfix-aifc.sh
.RunMe.command
into the Terminal window, then press return.