madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

initial checkin master
authormartin f. krafft <madduck@madduck.net>
Tue, 7 Apr 2020 20:44:50 +0000 (08:44 +1200)
committermartin f. krafft <madduck@madduck.net>
Tue, 7 Apr 2020 20:44:50 +0000 (08:44 +1200)
pulserecorder [new file with mode: 0755]

diff --git a/pulserecorder b/pulserecorder
new file mode 100755 (executable)
index 0000000..65a40ce
--- /dev/null
@@ -0,0 +1,181 @@
+#!/bin/sh
+#
+# pulserecorder — record a source via Pulse
+#
+# Synopsis:
+#   pulserecorder -i <index> [-o filename.ogg] [-f]
+#   pulserecorder -l         [-o filename.ogg] [-f]
+#   pulserecorder            [-o filename.ogg] [-f]
+#
+# The first form records sound from the sink-input with index <index> to the
+# filename specified, or one generated using a UUID.
+#
+# The second form records sound from the sink-input with the highest index,
+# i.e. the latest one to start playing.
+#
+# The third form waits for a new sink-input to appear, and will record that.
+#
+# Copyright © 2020 martin f. krafft <madduck@madduck.net>
+# Released under the teams of the Artistic Licence 2.0
+#
+set -eu
+
+if [ -z "${TMPDIR:-}" ]; then
+  TMPDIR=/tmp
+fi
+for i in $LOGNAME volatile; do
+  if [ -d "${TMPDIR}/$i" ]; then
+    TMPDIR="${TMPDIR}/$i"
+    break
+  fi
+done
+export TMPDIR
+TMPDIR=$(mktemp -dp "$TMPDIR" parec.XXXXXXXXXX)
+
+cleanup_commands="rm -r $TMPDIR"
+cleanup() {
+  set +e
+  eval $cleanup_commands 2>/dev/null
+  trap - 1 2 3 4 5 6 7 8 10 11 12 13 14 15
+}
+trap cleanup 1 2 3 4 5 6 7 8 10 11 12 13 14 15
+cleanup_hook() {
+  cleanup_commands="$@${cleanup_commands:+; $cleanup_commands}"
+}
+
+state= outfile= index= clobber=0
+for arg in "$@"; do
+  case "$state/$arg" in
+    (/-i) state=i;;
+    (i/*) index="$arg"; state=;;
+
+    (/-l) index="last";;
+
+    (/-o) state=o;;
+    (o/*) outfile="$arg"; state=;;
+
+    (/-f) clobber=1;;
+  esac
+done
+
+pa_subscribe() {
+  ( pactl subscribe 2>/dev/null & echo $! )
+}
+pa_wait_for_event() {
+  local type event id;
+  type="${1:-*}" event="${2:-*}" id="${3:-*}"
+  pa_subscribe | (
+    read pid
+    #echo >&2 pa_subscribe started with PID $pid
+    while read lead xevent on xtype xid; do
+      xid=${xid#\#}
+      #echo >&2 "pa_subscribe: $xtype/$xevent/$xid ($type/$event/$id)"
+      case "$xtype/$xevent/$xid" in
+        ($type/"'$event'"/$id) echo "$xtype $xevent $xid"; break;;
+        (*) :;;
+      esac
+    done
+    kill $pid
+  )
+}
+pa_get_next_index() {
+  pa_wait_for_event sink-input new | {
+    read type event id
+    echo $id
+  }
+}
+
+case "$index" in
+
+  (last)
+    index=$(pactl list short sink-inputs | sed -rne '$s,([[:digit:]]+)[[:space:]].*,\1,p')
+    ;;
+
+  (*[^0-9]*)
+    echo >&2 "E: non-numeric index: $index"
+    exit 1
+    ;;
+
+  (*[0-9]*) :;;
+
+  (*)
+    echo >&2 "Listening for pulseaudio event… "
+    index=$(pa_get_next_index)
+    ;;
+
+esac
+
+if [ -z "$index" ]; then
+
+  echo >&2 E: no index specified or discernable.
+  exit 1
+fi
+
+uuidgen() {
+  hascmd() { command -v "$@" >/dev/null;}
+  if   hascmd uuid; then uuid
+  elif hascmd uuidgen; then uuidgen
+  elif hascmd python3; then
+    python3 -c 'import uuid; print(uuid.uuid1())'
+  elif hascmd python; then
+    python -c 'import uuid; print(uuid.uuid1())'
+  else
+    dd if=/dev/urandom bs=16 count=1 status=none | base64
+  fi
+}
+
+uuid=$(uuidgen)
+
+[ -n "$outfile" ] || outfile="${uuid}.ogg"
+
+if [ -f "$outfile" ] && [ $clobber -eq 0 ]; then
+  echo >&2 "E: file exists, and -f not given: $outfile"
+  exit 1
+fi
+
+devname="record-to-file-${uuid}"
+
+echo >&2 "Recording source $index to $outfile …"
+
+load_module() {
+  local id mod sink desc
+  mod="$1" sink="$2" desc="$3"; shift 3
+  id=$(pactl load-module "$mod" sink_name="$sink" "$@")
+  cleanup_hook pactl unload-module $id
+  pacmd update-sink-proplist "$sink" device.description="$desc"
+}
+
+move_source_to_sink() {
+  local c;
+  c=$(pactl list short sink-inputs | sed -rne "s,^${1}[[:space:]]+([[:digit:]]+).+,\1,p")
+  cleanup_hook pactl move-sink-input $1 $c
+  pactl move-sink-input $1 $2
+}
+
+if false; then
+  # This would be great, but it does not work. For instance, trying this on
+  # audible meant that audible would play at maximum speed (3h played in 3
+  # minutes), but the result would be full of skips, making me think that
+  # something in the local pipeline can't handle the throughput. Rate-limiting
+  # might work. Have not had the time to investigate further.
+
+  load_module module-pipe-sink "$devname" "$devname" file="$TMPDIR/outfifo"
+  move_source_to_sink $index "$devname"
+  oggenc --raw -q5 -o "$outfile" "$TMPDIR/outfifo" || :
+  #pv -pterb "$TMPDIR/outfifo" > $TMPDIR/outfile.wav || :
+
+else
+  # More traditional approach, which just takes 1:1 time.
+
+  load_module module-null-sink "$devname" "$devname"
+  move_source_to_sink $index "$devname"
+  parec --format=s16le -d "${devname}.monitor" 2>/dev/null \
+    | oggenc --raw -q5 -o "$outfile" - &
+  pid=$!
+
+  pa_wait_for_event sink-input remove $index >/dev/null
+
+  kill $pid
+fi
+
+cleanup