Emergency server help: get in touch

Asterisk Recordings to MP3: Bulk Convert FreePBX Call Recordings

Converts Asterisk, FreePBX and Issabel call recordings (WAV, WAV49, GSM, µ-law, SLN) to small mono MP3 files from cron, verifies each file and keeps the FreePBX call log player working.

Version
1.1.0
Last updated
October 7, 2026
Language
Bash
Tested on
1.1.0 on Debian 12 with FreePBX 17 and Asterisk 22.11 (lab test, 7 Oct 2026): wav and gsm converted with --delete-original --update-cdr and the call log updated; a recording with no call-log row kept its original (exit 1); unreadable CDR database stops before any change; 16 failure fixtures (CDR error/0 rows, zero-length and truncated MP3, rename/owner/time failures, open files); Debian 12 with FreePBX 17.0 and Asterisk 22.11 (lab test with real MixMonitor recordings, 6 Oct 2026); Ubuntu 24.04 with ffmpeg 6.1, SoX 14.4.2 and LAME 3.100, sample Asterisk recordings (wav, WAV49, gsm, ulaw, sln16)
License
MIT
Pricing
Free

Call recordings fill a PBX disk faster than anything else. FreePBX writes 16-bit WAV by default, about 1 MB per minute, and older systems use GSM files that customers, managers and CRMs cannot play. This script converts finished Asterisk recordings to small mono MP3 files from cron, safely: it skips calls still being recorded, never converts a file twice, decodes every MP3 back and checks its length before touching the original, and can update the FreePBX call log so the web player keeps working.

Version 1.1.0 (7 October 2026): An external review found that version 1.0.0 could delete an original recording even when the call log update had failed, and accepted any non-empty MP3 as verified. Now the original is deleted only after every step worked, including a full read-back of the MP3 and a call log update that changed at least one row; on any failure the original is kept and the run exits 1.

What it does

  • Searches /var/spool/asterisk/monitor (or --dir) recursively for .wav, .WAV (WAV49), .wav49 and .gsm files, plus .ulaw, .alaw, .sln, .sln16, .g722 and .g729 when you list them in --ext.
  • Skips files modified in the last two minutes (--older-than), so a call that is still recording is left alone. Files changed in the last 60 seconds, held open (checked with fuser when it is installed) or changed during conversion are always skipped and counted as “still recording”. It also skips any file that already has an .mp3 next to it.
  • Converts with ffmpeg if it is installed, otherwise with sox piped into lame. Output is mono MP3 at 32 kbps by default (about 240 KB per minute).
  • Writes to a .mp3.part file first, then decodes every frame of the new MP3 and of the original (with ffprobe, or sox in sox mode). Both lengths must be over zero and within 1 second or 2% of each other, so zero-length, truncated or undecodable output is rejected. It then copies the owner, permissions and timestamp from the original and renames the file.
  • With --delete-original, deletes the original only when encoding, read-back, the owner/permission/time copy, the rename and (with --update-cdr) a call log update that changed at least one row all worked. If any step fails it prints ERROR: <file>: <reason>; original kept, removes the MP3 so the next run tries that file again, and the run exits 1. The one exception: if the call log already names the MP3, both files are kept and the error says so.
  • With --update-cdr, changes the recordingfile column in asteriskcdrdb.cdr from the old name to the .mp3 name, and checks that at least one row changed.
  • Without ffprobe or sox the MP3 cannot be read back. The script then only converts: it prints a WARN, keeps every original and leaves the call log alone, even with --delete-original and --update-cdr.
  • Takes a lock, so a slow run is never overlapped by the next cron start, and runs the encoders with nice so calls are not affected.

Install and first run

curl -fsSL https://srvscripts.com/get/asterisk-recordings-to-mp3/ -o /usr/local/bin/asterisk-recordings-to-mp3.sh
chmod 755 /usr/local/bin/asterisk-recordings-to-mp3.sh

# encoder: ffmpeg (Debian 12 / FreePBX 17, Ubuntu)  or  sox + lame
apt install ffmpeg            # or: apt install sox lame

# see what it would do, change nothing
asterisk-recordings-to-mp3.sh --dry-run

# convert everything older than 2 minutes, keep the originals
asterisk-recordings-to-mp3.sh

On AlmaLinux and Rocky Linux, ffmpeg comes from RPM Fusion; sox is usually in the base repositories and lame in EPEL or RPM Fusion. The ffmpeg package also gives you ffprobe for the read-back check. In sox mode the read-back needs a sox built with MP3 support; without it every file fails safely and its original is kept. The first run converts the whole backlog, so on a busy PBX start it at a quiet time, or limit it with --days 7.

Sample output

2026-10-03 05:21:21 converting with ffmpeg at 32 kbps in /var/spool/asterisk/monitor
ok  2026/10/02/out-101-20261002-1.wav -> out-101-20261002-1.mp3 (62 KB -> 16 KB)
ok  2026/10/02/in-102-20261002-7.WAV -> in-102-20261002-7.mp3 (6 KB -> 16 KB)
ok  2026/10/02/q-200-20261002-3.gsm -> q-200-20261002-3.mp3 (6 KB -> 16 KB)
2026-10-03 05:21:22 done: 3 converted, 0 already had an MP3, 0 still recording, 0 failed in 1 s, 74 KB -> 48 KB

This sample is shown in the output format of 1.1.0, which adds the “still recording” count to the summary line. A file that fails is reported on its own line, for example ERROR: 2026/10/07/test-nocdr.wav: call log update matched 0 rows; original kept, and the run exits 1.

GSM and WAV49 files are already tiny (13 kbps), so their MP3 is a little larger; the gain is that every phone, browser and CRM can play it. PCM WAV recordings shrink by about 75%.

Options

OptionDefaultWhat it does
--dir DIR/var/spool/asterisk/monitorFolder to search, including subfolders
--older-than MIN2Skip files modified in the last MIN minutes. Files changed in the last 60 seconds or held open are always skipped
--days N0 (all)Only files modified in the last N days
--bitrate KBPS3216, 24, 32, 48 or 64
--ext LISTwav,WAV,wav49,gsmExtensions to convert, case-sensitive
--delete-originaloffDelete each original only after its MP3 is read back and every other step worked. Needs ffprobe or sox; without them originals are kept
--update-cdroffPoint FreePBX call log rows at the MP3. A file whose update fails or matches no row keeps its original and is tried again next run
--cdr-db NAMEasteriskcdrdbCDR database name
--encoder NAMEautoffmpeg, sox or auto
--dry-runoffList what would be converted
-qoffPrint only the summary and errors

Exit codes: 0 when everything converted or there was nothing to do, 1 when some files failed (each one is printed as an ERROR line and keeps its original), 2 for a usage error, a missing encoder, another run still active, or a call log that cannot be read with --update-cdr. In that last case the script stops before it changes anything. The lock file is /run/lock/asterisk-recordings-to-mp3.lock; set RECORDINGS_MP3_LOCK to use another path.

Run it every 10 minutes from cron

# /etc/cron.d/recordings-mp3
*/10 * * * * root /usr/local/bin/asterisk-recordings-to-mp3.sh -q --older-than 5 --delete-original --update-cdr >>/var/log/recordings-mp3.log 2>&1

Run it without --delete-original for a few days first, play some MP3s from the call log, then add the option. Rotate the log with a small /etc/logrotate.d/recordings-mp3 entry. A file that failed needs no action to be retried: it keeps its original and has no .mp3, so the next cron run picks it up. Check the log for ERROR lines that repeat on every run.

FreePBX call log and web player

FreePBX finds a recording through the recordingfile column of asteriskcdrdb.cdr. If you delete the WAV and leave the column alone, the CDR Reports and UCP players show the call but cannot play it. --update-cdr runs one UPDATE … SET recordingfile=REPLACE(…) per file, matched on the file name, using the mysql client with the credentials in /root/.my.cnf (FreePBX sets this up) or MYSQL_ARGS, and reads back how many rows changed. If no row changed, the original is kept. Plain Asterisk without FreePBX does not need it.

A recording with no matching call log row therefore never converts under --update-cdr. That includes recordings the CDR does not reference, and names with _ or %, which the LIKE match treats as wildcards. Such a file fails and is encoded again on every run until you handle it by hand or run the script without --update-cdr. Empty (0-second) recordings are also reported on every run.

Version 1.0.0 was tested on a real FreePBX 17 server (Debian 12, Asterisk 22.11) on 6 October 2026: we made three calls that Asterisk recorded with MixMonitor in wav, gsm and WAV (wav49) format, each with its call-log row. The script converted all three, kept the asterisk owner, deleted the originals only after checking each MP3, and pointed the call log at the new files. The 7.7-second WAV shrank from 117 KB to 30 KB; the GSM files grew from 12 KB to 30 KB, because GSM is already smaller than 32 kbps MP3.

Terminal: Dry run, conversion with --delete-original --update-cdr, then the files, MP3 details and call-log rows. FreePBX 17, 6 Oct 2026.
Dry run, conversion with –delete-original –update-cdr, then the files, MP3 details and call-log rows. FreePBX 17, 6 Oct 2026.

Version 1.1.0 was tested on the same server on 7 October 2026. A 20-second wav and a 15-second gsm test recording, each with a call log row, were converted with --delete-original --update-cdr; the call log now pointed at the .mp3 files, and the MP3s read back as 20.16 s and 15.19 s. A third wav with no call log row failed with ERROR: 2026/10/07/test-nocdr.wav: call log update matched 0 rows; original kept, and the run exited 1. The summary line was done: 2 converted, 0 already had an MP3, 0 still recording, 1 failed in 2 s, 649 KB -> 138 KB. With wrong database credentials the script stopped before any change with ERROR: cannot read asteriskcdrdb.cdr (set MYSQL_ARGS or /root/.my.cnf) and exit code 2.

Notes

  • Asterisk cannot record to MP3 directly: its MP3 format module only reads MP3, for music on hold. Converting after the call is the standard approach.
  • Uppercase .WAV is GSM-compressed WAV49 and lowercase .wav is PCM; the script treats extensions case-sensitively for that reason.
  • sox cannot decode G.729 or G.722 files; use --encoder ffmpeg for those.
  • Run the script as root, or as the owner of the recordings. If the owner, mode or timestamp cannot be copied (for example as a non-root user on files owned by asterisk), each file now fails and keeps its original instead of being converted with the wrong owner.
  • The open-file check needs fuser (package psmisc) and root to see Asterisk’s file handles. Without it, the 60-second and changed-during-conversion checks still apply.
  • If the call log update errors after the database already points at the MP3, both files are kept and later runs skip that file. Clean it up by hand.
  • For one-off files on your own computer, the call recording converter does the same in the browser.

Asterisk recordings to MP3 at a glance

Asterisk Recordings to MP3 summary card: Call recordings fill a PBX disk faster than anything else.
In short: Call recordings fill a PBX disk faster than anything else.
Asterisk Recordings to MP3 sections: What it does, Install and first run, Sample output and Options
Covers: What it does, Install and first run, Sample output and Options.
Asterisk Recordings to MP3 questions answered: Will it convert a call that is still being recorded? Is it safe to delete the originals?
Answers: Will it convert a call that is still being recorded? Is it safe to delete the originals?

Official documentation: Asterisk MixMonitor, FFmpeg MP3 (libmp3lame) options, SoX manual.

Related: Call recording converter · Convert Asterisk recordings to MP3 · Install Asterisk 22 on Debian 13.

The script

asterisk-recordings-to-mp3.shDownload
#!/usr/bin/env bash
# Asterisk Recordings to MP3: Bulk Convert FreePBX Call Recordings (v1.1.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/asterisk-recordings-to-mp3/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
# asterisk-recordings-to-mp3.sh — convert Asterisk / FreePBX / Issabel call recordings to MP3 from cron
# https://srvscripts.com/scripts/asterisk-recordings-to-mp3/   License: MIT
#
# Finds finished recordings (.wav, .WAV/wav49, .gsm, .ulaw, .alaw, .sln...) under the monitor folder, converts
# each one to mono MP3 with ffmpeg (or sox + lame), copies the owner and timestamp, decodes the MP3 back to check
# its length matches the original and, only if asked, points the FreePBX call log (asteriskcdrdb.cdr.recordingfile)
# at the new file and deletes the original. Files still being written (modified in the last --older-than minutes,
# always at least 60 seconds, or held open) are skipped. Safe to run every few minutes: a lock stops two runs
# overlapping and finished files are never converted twice.
# The original is deleted only when encoding, read-back, owner/time copy, rename and (with --update-cdr) the call
# log update of at least one row all succeeded. On any failure the original is kept, the MP3 is removed (so the
# next run retries the file), an ERROR line is printed and the run exits 1. Without ffprobe or sox the MP3 cannot
# be read back, so the script only converts: originals are kept and the call log is left alone.
#   bash asterisk-recordings-to-mp3.sh --dry-run
#   bash asterisk-recordings-to-mp3.sh --older-than 5 --delete-original --update-cdr
#   */10 * * * * root /usr/local/bin/asterisk-recordings-to-mp3.sh --delete-original --update-cdr >>/var/log/recordings-mp3.log 2>&1
# Exit codes: 0 all converted (or nothing to do), 1 some files failed, 2 usage error, no encoder, or another run active.
# Version 1.1.0 (2026-10-07): originals are kept unless every step succeeded, including the call log update (1.0.0
#   deleted them after a failed UPDATE); MP3s are decoded and must match the original's length (zero-length or
#   truncated output is rejected); owner/time/rename failures count as errors; open recordings are skipped.
# Version 1.0.0: first release.
set -uo pipefail
export LC_ALL=C

DIR=/var/spool/asterisk/monitor; OLDER=2; BITRATE=32; EXTS="wav,WAV,wav49,gsm"; DELETE=0; CDR=0
DRY=0; DAYS=0; ENCODER=auto; QUIET=0; LOCK=${RECORDINGS_MP3_LOCK:-/run/lock/asterisk-recordings-to-mp3.lock}; CDRDB=asteriskcdrdb

usage() {
  cat <<'EOF'
Usage: asterisk-recordings-to-mp3.sh [options]
  --dir DIR            recordings folder (default /var/spool/asterisk/monitor), searched recursively
  --older-than MIN     skip files modified in the last MIN minutes, still recording (default 2;
                       files changed in the last 60 seconds or held open are always skipped)
  --days N             only files modified in the last N days (default 0 = all)
  --bitrate KBPS       MP3 bit rate: 16, 24, 32, 48 or 64 (default 32)
  --ext LIST           extensions to convert, comma separated (default wav,WAV,wav49,gsm;
                       also: ulaw,alaw,sln,sln16,g722,g729 with ffmpeg)
  --delete-original    delete each original after its MP3 is verified (default: keep both;
                       needs ffprobe or sox to read the MP3 back, otherwise originals are kept)
  --update-cdr         FreePBX: change recordingfile in asteriskcdrdb.cdr to the .mp3 name; a file
                       whose update fails or matches no row keeps its original and is retried next run
  --cdr-db NAME        CDR database name (default asteriskcdrdb)
  --encoder NAME       auto | ffmpeg | sox (default auto: ffmpeg if installed, else sox + lame)
  --dry-run            only list what would be converted
  -q, --quiet          print only the summary and errors
  -h, --help           this help
Environment: MYSQL_ARGS extra arguments for the mysql client (FreePBX root can use /root/.my.cnf)
             RECORDINGS_MP3_LOCK lock file (default /run/lock/asterisk-recordings-to-mp3.lock)
EOF
}
die() { echo "ERROR: $*" >&2; exit 2; }
log() { [ "$QUIET" = 1 ] || echo "$*"; }

while [ $# -gt 0 ]; do
  case "$1" in
    --dir) DIR="${2:-}"; shift 2 ;;
    --older-than) OLDER="${2:-}"; shift 2 ;;
    --days) DAYS="${2:-}"; shift 2 ;;
    --bitrate) BITRATE="${2:-}"; shift 2 ;;
    --ext) EXTS="${2:-}"; shift 2 ;;
    --delete-original) DELETE=1; shift ;;
    --update-cdr) CDR=1; shift ;;
    --cdr-db) CDRDB="${2:-}"; shift 2 ;;
    --encoder) ENCODER="${2:-}"; shift 2 ;;
    --dry-run) DRY=1; shift ;;
    -q|--quiet) QUIET=1; shift ;;
    -h|--help) usage; exit 0 ;;
    *) usage >&2; exit 2 ;;
  esac
done

[[ "$OLDER" =~ ^[0-9]+$ ]] || die "--older-than needs a number of minutes"
[[ "$DAYS" =~ ^[0-9]+$ ]] || die "--days needs a number"
case "$BITRATE" in 16|24|32|48|64) ;; *) die "--bitrate must be 16, 24, 32, 48 or 64" ;; esac
[[ "$CDRDB" =~ ^[A-Za-z0-9_]+$ ]] || die "--cdr-db: letters, digits and _ only"
[ -d "$DIR" ] || die "folder not found: $DIR"

have() { command -v "$1" >/dev/null 2>&1; }
if [ "$ENCODER" = auto ]; then
  if have ffmpeg; then ENCODER=ffmpeg; elif have sox && have lame; then ENCODER=sox; else ENCODER=none; fi
fi
case "$ENCODER" in
  ffmpeg) have ffmpeg || die "ffmpeg not found" ;;
  sox) { have sox && have lame; } || die "sox and lame are both needed (dnf install sox lame / apt install sox lame)" ;;
  *) die "no encoder: install ffmpeg, or sox and lame (on EL: dnf install epel-release, then dnf install sox lame)" ;;
esac
# Reading the MP3 back needs ffprobe (ffmpeg package) or sox. Without one nothing is verified: convert only.
if have ffprobe; then PROBE=ffprobe; elif have sox; then PROBE=sox; else PROBE=none; fi
if [ "$PROBE" = none ] && [ "$DRY" = 0 ] && { [ "$DELETE" = 1 ] || [ "$CDR" = 1 ]; }; then
  echo "WARN: no ffprobe or sox to read the MP3s back: converting only, originals are kept and the call log is not changed" >&2
  DELETE=0; CDR=0
fi
if [ "$CDR" = 1 ] && [ "$DRY" = 0 ]; then
  MYSQL=$(command -v mysql || command -v mariadb) || die "--update-cdr needs the mysql or mariadb client"
  # shellcheck disable=SC2086
  "$MYSQL" ${MYSQL_ARGS:-} -N -B -e "SELECT 1 FROM \`$CDRDB\`.cdr LIMIT 1" >/dev/null 2>&1 || die "cannot read $CDRDB.cdr (set MYSQL_ARGS or /root/.my.cnf)"
fi

# One run at a time.
if [ "$DRY" = 0 ]; then
  mkdir -p "$(dirname "$LOCK")" 2>/dev/null
  exec 9>"$LOCK" || die "cannot open lock file $LOCK"
  flock -n 9 || { echo "Another run is still active, exiting." >&2; exit 2; }
fi

# Input options for headerless formats.
ff_in() {
  case "${1,,}" in
    gsm) echo "-f gsm -ar 8000 -ac 1" ;;
    ulaw|ul|mu|pcmu) echo "-f mulaw -ar 8000 -ac 1" ;;
    alaw|al|pcma) echo "-f alaw -ar 8000 -ac 1" ;;
    sln|slin|raw) echo "-f s16le -ar 8000 -ac 1" ;;
    sln12) echo "-f s16le -ar 12000 -ac 1" ;; sln16) echo "-f s16le -ar 16000 -ac 1" ;;
    sln24) echo "-f s16le -ar 24000 -ac 1" ;; sln32) echo "-f s16le -ar 32000 -ac 1" ;;
    sln48) echo "-f s16le -ar 48000 -ac 1" ;;
    g722) echo "-f g722" ;; g729) echo "-f g729" ;;
    mp3) echo "-f mp3" ;; # read-back of our own .mp3.part
    *) echo "" ;;
  esac
}
sox_in() {
  case "${1,,}" in
    gsm) echo "-t gsm -r 8000 -c 1" ;;
    ulaw|ul|mu|pcmu) echo "-t ul -r 8000 -c 1" ;;
    alaw|al|pcma) echo "-t al -r 8000 -c 1" ;;
    sln|slin|raw) echo "-t raw -e signed -b 16 -r 8000 -c 1" ;;
    sln16) echo "-t raw -e signed -b 16 -r 16000 -c 1" ;;
    sln48) echo "-t raw -e signed -b 16 -r 48000 -c 1" ;;
    wav|wav49) echo "-t wav" ;;
    mp3) echo "-t mp3" ;;
    g722|g729) echo "UNSUPPORTED" ;;
    *) echo "" ;;
  esac
}
convert() { # $1 input  $2 output.part  $3 extension
  local in="$1" out="$2" ext="$3" opts
  if [ "$ENCODER" = ffmpeg ]; then
    opts=$(ff_in "$ext")
    # shellcheck disable=SC2086
    nice -n 15 ffmpeg -nostdin -hide_banner -loglevel error -y $opts -i "$in" -vn -ac 1 -c:a libmp3lame -b:a "${BITRATE}k" -f mp3 "$out"
  else
    opts=$(sox_in "$ext")
    [ "$opts" = UNSUPPORTED ] && { echo "sox cannot decode .$ext, use --encoder ffmpeg" >&2; return 1; }
    # shellcheck disable=SC2086
    nice -n 15 sox -q $opts "$in" -t wav -c 1 -b 16 - 2>/dev/null | nice -n 15 lame --quiet -m m -b "$BITRATE" - "$out"
  fi
}
duration() { # $1 file  $2 extension: seconds of audio found by decoding every frame, nothing if unreadable
  local opts
  if [ "$PROBE" = ffprobe ]; then # header durations are not trusted: a truncated MP3 keeps its full-length Xing header
    opts=$(ff_in "$2")
    # shellcheck disable=SC2086
    nice -n 15 ffprobe -v error $opts -select_streams a:0 -show_entries stream=sample_rate:frame=nb_samples -of default=nw=1 "$1" 2>&1 \
      | awk -F= '$1=="nb_samples" {n+=$2; next} $1=="sample_rate" {r=$2; next} {bad=1} END {if (!bad && r>0) printf "%.2f\n", n/r}'
  else
    opts=$(sox_in "$2"); [ "$opts" = UNSUPPORTED ] && return 1
    # shellcheck disable=SC2086
    nice -n 15 sox $opts "$1" -n stat 2>&1 | awk '/^sox FAIL/ {bad=1} /^Length \(seconds\)/ {d=$3} END {if (!bad && d!="") print d}'
  fi
}
verify() { # $1 mp3  $2 original  $3 extension: both decode to the same positive length (within 1 s or 2%)
  local a b
  [ -s "$1" ] || { why="empty MP3"; return 1; }
  b=$(duration "$1" mp3); a=$(duration "$2" "$3")
  awk -v a="${a:-0}" -v b="${b:-0}" 'BEGIN {t=a*0.02; if (t<1) t=1; d=a-b; if (d<0) d=-d; exit !(a>0 && b>0 && d<=t)}' \
    || { why="MP3 reads back as ${b:-unreadable}${b:+ s}, the original as ${a:-unreadable}${a:+ s}"; return 1; }
}
sql_escape() { printf "%s" "$1" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g"; }
cdr_sql() { # shellcheck disable=SC2086
  "$MYSQL" ${MYSQL_ARGS:-} -N -B -e "$1"; }

# Build the find expression from the extension list (case-sensitive: Asterisk's .WAV is WAV49, .wav is PCM).
NAMES=(); IFS=',' read -r -a EXT_LIST <<<"$EXTS"
for e in "${EXT_LIST[@]}"; do
  e="${e#.}"; [[ "$e" =~ ^[A-Za-z0-9]+$ ]] || die "bad extension: $e"
  [ "${e,,}" = mp3 ] && die "mp3 cannot be a source extension"
  [ ${#NAMES[@]} -gt 0 ] && NAMES+=(-o); NAMES+=(-name "*.$e")
done
FIND=(find "$DIR" -type f \( "${NAMES[@]}" \) -mmin "+$OLDER")
[ "$DAYS" -gt 0 ] && FIND+=(-mtime "-$DAYS")

ok=0; skip=0; busy=0; fail=0; before=0; after=0; start=$(date +%s)
log "$(date '+%F %T') converting with $ENCODER at ${BITRATE} kbps in $DIR$([ "$DRY" = 1 ] && echo ' (dry run)')"
while IFS= read -r -d '' f; do
  ext="${f##*.}"; base="${f%.*}"; mp3="$base.mp3"
  if [ -e "$mp3" ]; then skip=$((skip+1)); continue; fi
  # Still being written: changed in the last minute (whatever --older-than says) or held open by Asterisk.
  m0=$(stat -c %Y:%s "$f" 2>/dev/null) || continue
  if [ $(( $(date +%s) - ${m0%%:*} )) -lt 60 ] || { have fuser && fuser -s "$f" 2>/dev/null; }; then
    log "skip (still recording): ${f#"$DIR"/}"; busy=$((busy+1)); continue
  fi
  if [ "$DRY" = 1 ]; then log "would convert: $f"; ok=$((ok+1)); continue; fi
  part="$base.mp3.part"; why=""
  if ! convert "$f" "$part" "$ext"; then why="encoder failed"
  elif [ "$(stat -c %Y:%s "$f" 2>/dev/null)" != "$m0" ]; then
    rm -f -- "$part"; log "skip (changed while converting): ${f#"$DIR"/}"; busy=$((busy+1)); continue
  elif [ "$PROBE" != none ]; then verify "$part" "$f" "$ext"
  elif [ ! -s "$part" ]; then why="empty MP3"
  fi
  if [ -z "$why" ]; then
    { chown --reference="$f" "$part" && chmod --reference="$f" "$part" && touch -r "$f" "$part"; } || why="cannot copy owner, mode or time"
  fi
  if [ -z "$why" ]; then mv -f -- "$part" "$mp3" || why="cannot rename ${part##*/}"; fi
  if [ -z "$why" ] && [ "$CDR" = 1 ]; then
    old=$(sql_escape "${f##*/}"); new=$(sql_escape "${mp3##*/}")
    rows=$(cdr_sql "UPDATE \`$CDRDB\`.cdr SET recordingfile=REPLACE(recordingfile,'$old','$new') WHERE recordingfile LIKE '%$old'; SELECT ROW_COUNT()") || rows=""
    if ! [[ "$rows" =~ ^[0-9]+$ ]] || [ "$rows" -lt 1 ]; then
      why="call log update failed"; [[ "$rows" =~ ^[0-9]+$ ]] && why="call log update matched $rows rows"
      # Keep the MP3 only if the call log already names it (playback must work); otherwise remove it so the next run retries.
      if [[ "$(cdr_sql "SELECT COUNT(*) FROM \`$CDRDB\`.cdr WHERE recordingfile LIKE '%$new'")" =~ ^[1-9] ]]; then
        why="$why, but the call log already names ${mp3##*/} (kept both files, check by hand)"
      else rm -f -- "$mp3"; fi
    fi
  fi
  if [ -n "$why" ]; then
    rm -f -- "$part"; echo "ERROR: ${f#"$DIR"/}: $why; original kept" >&2; fail=$((fail+1)); continue
  fi
  sz_in=$(stat -c %s "$f"); sz_out=$(stat -c %s "$mp3"); before=$((before+sz_in)); after=$((after+sz_out))
  if [ "$DELETE" = 1 ] && ! rm -f -- "$f"; then
    echo "ERROR: ${f#"$DIR"/}: converted but the original could not be deleted" >&2; fail=$((fail+1)); continue
  fi
  log "ok  ${f#"$DIR"/} -> ${mp3##*/} ($((sz_in/1024)) KB -> $((sz_out/1024)) KB)$([ "$PROBE" = none ] && echo ', not verified')"
  ok=$((ok+1))
done < <("${FIND[@]}" -print0 2>/dev/null)

hs() { if [ "$1" -ge 1048576 ]; then echo "$(( $1 / 1048576 )) MB"; else echo "$(( $1 / 1024 )) KB"; fi; }
saved=""; [ "$after" -gt 0 ] && saved=", $(hs "$before") -> $(hs "$after")"
echo "$(date '+%F %T') done: $ok $([ "$DRY" = 1 ] && echo 'to convert' || echo converted), $skip already had an MP3, $busy still recording, $fail failed in $(( $(date +%s) - start )) s$saved"
[ "$fail" -eq 0 ] || exit 1
exit 0
Version 1.1.0 · SHA-256 ba1f009e6ceed2d1e77d789a6a718b20079ad48a847a9e4e95e536c25bb29c5a
Download and verify on Linux or macOS
curl -fsSL -o asterisk-recordings-to-mp3.sh https://scr.srvscripts.com/asterisk-recordings-to-mp3/asterisk-recordings-to-mp3.sh && curl -fsSL https://scr.srvscripts.com/asterisk-recordings-to-mp3/asterisk-recordings-to-mp3.sh.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/asterisk-recordings-to-mp3/asterisk-recordings-to-mp3.sh' -OutFile 'asterisk-recordings-to-mp3.sh'; if ((Get-FileHash 'asterisk-recordings-to-mp3.sh' -Algorithm SHA256).Hash -eq 'BA1F009E6CEED2D1E77D789A6A718B20079AD48A847A9E4E95E536C25BB29C5A') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }
Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.
Also on GitHub: github.com/srvscripts/scripts

Frequently asked questions

Will it convert a call that is still being recorded?

No. Files modified in the last two minutes are skipped by default (--older-than), and MixMonitor keeps writing to the file until the call ends. Files changed in the last 60 seconds, held open or changed during conversion are always skipped. Raise the value if you record very long calls with long silences.

Is it safe to delete the originals?

The script deletes an original only with --delete-original, and only after the MP3 is fully decoded with a length that matches the original, the owner and timestamp are copied, the file is renamed and, with --update-cdr, the call log update changed at least one row. If any step fails, the original is kept and the run exits 1. Without ffprobe or sox nothing is deleted. Run it without that option for a few days first.

Does FreePBX still play the recordings after conversion?

Yes, if you use --update-cdr, which points each call log row at the new MP3. Browsers play MP3 natively in CDR Reports and UCP. A recording with no matching call log row keeps its original and is reported as an error.

Which bit rate should I use?

32 kbps mono is clear for phone audio and about 240 KB per minute. 16 kbps halves that and is still easy to understand; use 48 kbps or more only for wideband (G.722 or Opus) calls.

Changelog

  • 1.1.0 — Originals are deleted only when encoding, full read-back (positive length matching the original within 1 s or 2%), owner/time copy, rename and, with --update-cdr, a call-log update of at least one row all succeeded. Any failure keeps the original, removes the MP3 for a retry next run and exits 1 (1.0.0 deleted the original after a failed CDR update). Without ffprobe or sox nothing is deleted. Recordings changed in the last 60 seconds or still open are skipped. Found in an external review (PROD7-03).
  • 1.0.0 — Initial release

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.