aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.vscode/launch.json13
-rw-r--r--README.md48
-rwxr-xr-xdoc/img/sim7600-playback.720p.30fps.mp4bin0 -> 827875 bytes
-rw-r--r--doc/mmfwd.sample.yaml8
-rw-r--r--src/mmfwd-callam.cpp154
-rw-r--r--src/mmfwd/__init__.py18
-rw-r--r--src/mmfwd/recsync/__init__.py125
-rw-r--r--src/mmfwd/recsync/__main__.py9
-rw-r--r--src/mmfwd/recsync/exceptions.py2
9 files changed, 351 insertions, 26 deletions
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 1a65bdf..04398d4 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -5,11 +5,22 @@
"version": "0.2.0",
"configurations": [
{
- "name": "Python Debugger: Module",
+ "name": "Python Debugger: Module mmfwd",
"type": "debugpy",
"request": "launch",
"module": "mmfwd",
"cwd": "${workspaceFolder}/src",
+ },
+ {
+ "name": "Python Debugger: Module mmfwd.recsync",
+ "type": "debugpy",
+ "request": "launch",
+ "module": "mmfwd.recsync",
+ "cwd": "${workspaceFolder}/src",
+ "env": {
+ // your s3 prefix here, with or without trailing slash
+ "MMFWD_RECSYNC_S3_UPLOAD_PREFIX": "s3://.../mmfwd/rec/"
+ }
}
]
}
diff --git a/README.md b/README.md
index c29fbe2..ef0b39e 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,54 @@ To encode the recorded raw audio files:
ffmpeg -ar 16000 -ac 1 -f s16le -i PCM_FILE -c:a libopus OUTFILE.ogg
```
+## Local playback
+Config to enable. `1` for incoming audio only, `2` for outgoing audio only, `3`
+for both. `0` to disable:
+
+```yaml
+mmfwd:
+ instances:
+ -
+...
+ call-am:
+...
+ playback: 1
+...
+```
+
+### Enable ringtone (optional)
+https://github.com/user-attachments/assets/209f5bdd-2e63-4373-81d2-5a47c403db82
+
+<video controls src="doc/img/sim7600-playback.720p.30fps.mp4" title="sim7600-playback.720p.30fps.mp4"></video>
+
+Make a simple ringtone:
+
+```sh
+# short bursts of alternating 250 and 500 Hz sine waves
+truncate -s 0 bell.pcm
+for (( i = 0; i < 6; i += 1 ))
+do
+ ffmpeg -loglevel error -f lavfi -i "sine=frequency=250:duration=0.1" -f s16le -ar 16000 -ac 1 - >> bell.pcm
+ ffmpeg -loglevel error -f lavfi -i "sine=frequency=500:duration=0.2" -f s16le -ar 16000 -ac 1 - >> bell.pcm
+done
+
+# encode it to opus
+ffmpeg -loglevel error -f s16le -ar 16000 -ac 1 -i bell.pcm -c:a libopus ringtone.ogg
+```
+
+Config to enable ringtone:
+
+```yaml
+ ringtone-exec:
+ - ffplay
+ - -loglevel
+ - error
+ - -autoexit
+ - -nodisp
+ - ringtone.ogg
+...
+```
+
## MM Patches
- https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/merge_requests/1293
- https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/996
diff --git a/doc/img/sim7600-playback.720p.30fps.mp4 b/doc/img/sim7600-playback.720p.30fps.mp4
new file mode 100755
index 0000000..7228f0b
--- /dev/null
+++ b/doc/img/sim7600-playback.720p.30fps.mp4
Binary files differ
diff --git a/doc/mmfwd.sample.yaml b/doc/mmfwd.sample.yaml
index 0d4396a..f0e9d91 100644
--- a/doc/mmfwd.sample.yaml
+++ b/doc/mmfwd.sample.yaml
@@ -12,3 +12,11 @@ mmfwd:
call-am:
enabled: false
exec: "/usr/local/mmfwd-callam"
+ playback: 0
+ ringtone-exec:
+ - ffplay
+ - -loglevel
+ - error
+ - -autoexit
+ - -nodisp
+ - ringtone.ogg
diff --git a/src/mmfwd-callam.cpp b/src/mmfwd-callam.cpp
index 90ad4d2..3104dd7 100644
--- a/src/mmfwd-callam.cpp
+++ b/src/mmfwd-callam.cpp
@@ -31,10 +31,7 @@ struct {
const char *hello_sample_pat; // glob patterns
const char *outfile_prefix;
struct {
- const char *sample_rate;
- const char *channels;
- const char *fmt;
- bool playback; // TODO
+ bool enabled[2];
} playback;
int vl;
} params;
@@ -66,9 +63,9 @@ size_t getrndsize (void) {
void init_params (void) {
params.hello_sample_pat = "hello/*.pcm";
- params.playback.sample_rate = "8000";
- params.playback.channels = "1";
- params.playback.fmt = "S16_LE";
+ // params.playback.sample_rate = "8000";
+ // params.playback.channels = "1";
+ // params.playback.fmt = "S16_LE";
}
void init_g (void) {
@@ -77,6 +74,108 @@ void init_g (void) {
g.playback[0] = g.playback[1] = -1;
}
+void parse_env (void) {
+ const char *env;
+
+ env = getenv("MMFWD_CALLAM_PLAYBACK");
+ if (env != NULL) {
+ int v = 0;
+
+ sscanf(env, "%d", &v);
+ params.playback.enabled[0] = v & 1 ? true : false;
+ params.playback.enabled[1] = v & 2 ? true : false;
+ }
+}
+
+pid_t do_playback_exec (const int p_stdin, const int p_stdout) {
+ pid_t pid;
+ int fr;
+
+ pid = fork();
+ if (pid < 0) {
+ return -1;
+ }
+ else if (pid > 0) {
+ return pid;
+ }
+
+ close(STDIN_FILENO);
+ close(STDOUT_FILENO);
+ dup2(p_stdin, STDIN_FILENO);
+ dup2(p_stdout, STDOUT_FILENO);
+ close(p_stdin);
+ close(p_stdout);
+
+ fr = system(
+ "ffmpeg -loglevel error -f s16le -ar 16000 -ac 1 -i pipe: -filter:a loudnorm -ar 16000 -ac 1 -f s16le - | "
+ "aplay -q -r 16000 -c 1 -f S16_LE -"
+ );
+ if (fr < 0) {
+ perror(ARGV0 ": system()");
+ abort();
+ }
+ exit(fr);
+
+ assert("unreachable" && false);
+}
+
+bool open_playback (void) {
+ int p[2];
+ int fr;
+ pid_t pid[2] = { -1, -1 };
+ int fd_in[2] = { -1, -1 };
+ int fd_out[2] = { -1, -1 };
+ int blackhole;
+
+ fr = pipe(p);
+ if (fr < 0) {
+ perror(ARGV0 ": pipe()");
+ goto ERR;
+ }
+ close(p[0]);
+ blackhole = p[1];
+
+ for (size_t i = 0; i < 2; i += 1) {
+ if (!params.playback.enabled[i]) {
+ continue;
+ }
+
+ fr = pipe(p);
+ if (fr < 0) {
+ perror(ARGV0 ": pipe()");
+ goto ERR;
+ }
+ fd_in[i] = p[0];
+ fd_out[i] = p[1];
+
+ pid[i] = do_playback_exec(p[0], blackhole);
+ if (pid[i] < 0) {
+ goto ERR;
+ }
+
+ g.playback[i] = p[1];
+ close(fd_in[i]);
+ fd_in[i] = -1;
+
+ // Let audio skip when the buffer gets full.
+ // Should be fine as long as the size of the buffer is aligned to 2 byte
+ // boundary which is always the case.
+ fr = fcntl(g.playback[i], F_GETFL);
+ fr |= O_NONBLOCK;
+ fr = fcntl(g.playback[i], F_SETFL);
+ assert(fr == 0);
+ }
+
+ return true;
+ERR: // house-keeping
+ for (size_t i = 0; i < 2; i += 1) {
+ close(fd_in[i]);
+ close(fd_out[i]);
+ kill(pid[i], SIGHUP);
+ }
+ return false;
+}
+
bool open_dev (void) {
struct termios tis;
const int fd = open(params.dev, O_RDWR | O_NONBLOCK);
@@ -209,6 +308,23 @@ bool write_full (const int fd, const void *m, size_t len, const std::string &fn)
return true;
}
+void sink_playback_audio (int *fd, const void *buf, const size_t len) {
+ ssize_t iofr;
+
+ if (*fd < 0) {
+ return;
+ }
+
+ iofr = write(*fd, buf, len);
+ // Errors are not fatal:
+ // - skip some samples when the child process struggle/stopped
+ // - EPIPE in case of death of child process
+ if (iofr < 0 && errno == EPIPE) {
+ close(*fd);
+ *fd = -1;
+ }
+}
+
bool loop_main (void) {
uint8_t buf[4096];
size_t sample_pos = 0;
@@ -259,14 +375,7 @@ bool loop_main (void) {
if (!write_full(g.fd[0], buf, blen, g.outfile[0])) {
return false;
}
-
- if (g.playback[0] >= 0) {
- iofr = write(g.playback[0], buf, blen);
- if (iofr < 0) {
- close(g.playback[0]);
- g.playback[0] = -1;
- }
- }
+ sink_playback_audio(&g.playback[0], buf, blen);
}
if (pfd.events & POLLOUT) {
@@ -294,14 +403,7 @@ bool loop_main (void) {
if (!write_full(g.fd[1], ptr, blen, g.outfile[1])) {
return false;
}
-
- if (g.playback[1] >= 0) {
- iofr = write(g.playback[1], ptr, blen);
- if (iofr < 0) {
- close(g.playback[1]);
- g.playback[1] = -1;
- }
- }
+ sink_playback_audio(&g.playback[1], buf, blen);
sample_pos += blen;
if (sample_pos >= g.hsample.len) {
@@ -328,6 +430,8 @@ void handle_termsig (int) {
int main (const int argc, const char **argv) {
init_params();
init_g();
+
+ parse_env();
// TODO: getopt()
if (argc <= 2) {
@@ -340,7 +444,9 @@ int main (const int argc, const char **argv) {
params.dev = argv[1];
params.outfile_prefix = argv[2];
- if (!open_dev() || !open_hsample() || !open_fds()) {
+ signal(SIGPIPE, SIG_IGN);
+
+ if (!open_playback() || !open_dev() || !open_hsample() || !open_fds()) {
return 1;
}
diff --git a/src/mmfwd/__init__.py b/src/mmfwd/__init__.py
index 17d1485..2c34d99 100644
--- a/src/mmfwd/__init__.py
+++ b/src/mmfwd/__init__.py
@@ -70,6 +70,7 @@ class Instance:
'enabled': False
})
self.callam_proc = None
+ self.callam_ringtone_proc = None
self.callam_timer = None
def match (self, m) -> bool:
@@ -325,6 +326,11 @@ class Application:
else:
call.hangup(None, self.on_call_hangup, ud)
+ if ud.instance.callam_ringtone_proc is not None:
+ ud.instance.callam_ringtone_proc.terminate()
+ ud.instance.callam_ringtone_proc.wait()
+ ud.instance.callam_ringtone_proc = None
+
if ud.instance.callam_proc is not None:
ud.instance.callam_proc.terminate()
ud.instance.callam_proc.wait()
@@ -359,6 +365,13 @@ class Application:
print("on_call_accept()") # FIXME
call.connect('state-changed', self.on_call_change, ud)
+ if ud.instance.callam.get('ringtone-exec'):
+ try:
+ ud.instance.callam_ringtone_proc = subprocess.Popen(
+ ud.instance.callam['ringtone-exec'])
+ except Exception as e:
+ sys.stderr.write(e + os.linesep)
+
# The custom ModemManager will send AT+CPCMREG.
# mmfwd-callam process will set up the serial, play the hello message
# and record
@@ -373,8 +386,11 @@ class Application:
os.makedirs(dir, exist_ok = True)
+ env = os.environ.copy()
+ env['MMFWD_CALLAM_PLAYBACK'] = str(ud.instance.callam['playback'])
+
exec = [ ud.instance.callam['exec'], "/dev/" + ud.audio_port, path ]
- ud.instance.callam_proc = subprocess.Popen(exec)
+ ud.instance.callam_proc = subprocess.Popen(exec, env = env)
# 5 minutes timeout
ud.instance.callam_timer = GLib.timeout_add_seconds(
60 * 4,
diff --git a/src/mmfwd/recsync/__init__.py b/src/mmfwd/recsync/__init__.py
new file mode 100644
index 0000000..32d0fc1
--- /dev/null
+++ b/src/mmfwd/recsync/__init__.py
@@ -0,0 +1,125 @@
+import datetime
+from fcntl import LOCK_EX, flock
+import glob
+from io import SEEK_SET
+import os
+import subprocess
+import sys
+from typing import assert_never
+from .exceptions import *
+
+def acquire_plock ():
+ f = open("mmfwd-recsync.pid", "w+")
+ try:
+ flock(f, LOCK_EX)
+ f.write(str(os.getpid()))
+ f.flush()
+
+ return f
+ except OSError as e:
+ f.seek(0, SEEK_SET)
+ pid = f.read(255).strip()
+ f.close()
+
+ raise MMFWDRSProcessLockException(
+ "Process lock failed (Another process: %s)" % (pid),
+ e)
+
+ assert_never()
+
+def strip_fileext (x: str) -> str:
+ last_dot = x.rfind('.')
+ last_sep = x.rfind(os.path.sep)
+
+ # the last . is after the last /
+ # or there's no /, but dot exists
+ if last_dot >= 0 and last_sep < last_dot:
+ # then, it's safe to strip
+ return x[:last_dot]
+ # there's no .
+ return x
+
+
+def proc_dir (dir: str):
+ # including trailing '/' if desired
+ s3_upload_prefix = os.getenv('MMFWD_RECSYNC_S3_UPLOAD_PREFIX')
+ # minimum audio length: 5 seconds
+ minsize = 5 * 1 * 2 * 16000 # seconds * nb_channels * bytes_per_sample * rate
+
+ for path in glob.glob(dir + "/*.pcm"): # for each raw audio file,
+ if not os.path.isfile(path):
+ continue
+
+ # do stat()
+ stat = os.stat(path)
+
+ # delete short recordings
+ if stat.st_size < minsize:
+ sys.stderr.write(
+ "mmfwd.recsync: deleting short recording: " +
+ path +
+ os.linesep)
+ os.remove(path)
+ continue
+
+ # ignore if less than an hour as a security measure against KYC attempts
+ now = datetime.datetime.now(datetime.UTC)
+ f_mtime = datetime.datetime.fromtimestamp(stat.st_mtime, datetime.UTC)
+ if now - f_mtime < datetime.timedelta(hours = 1.0):
+ continue
+
+ # be defensive: align the original raw audio file to 2 bytes boundary
+ # so that ffmpeg won't complain
+ fsize = stat.st_size
+ if fsize % 2 != 0:
+ fsize = (fsize // 2) * 2
+ os.truncate(path, fsize)
+
+ # sep file ext, fabricate output file names
+ basename = strip_fileext(os.path.basename(path))
+ s3_basename = "%d-%02d/%s" % (f_mtime.year, f_mtime.month, basename)
+ local_basepath = os.path.dirname(path) + '/' + basename
+ s3_basepath = s3_upload_prefix + s3_basename
+ local_outf_flac = local_basepath + ".flac"
+ local_outf_ogg = local_basepath + ".ogg"
+ s3_outf_flac = s3_basepath + ".flac"
+ s3_outf_ogg = s3_basepath + ".ogg"
+
+ # run ffmpeg for FLAC, lossless original not post processed
+ subprocess.run([
+ "ffmpeg", "-nostdin", "-loglevel", "error",
+ "-f", "s16le", "-ac", "1", "-ar", "16000",
+ "-i", path,
+ "-y",
+ local_outf_flac
+ ], check = True)
+ # run ffmpeg for opus OGG w/ RMS normalisation
+ subprocess.run([
+ "ffmpeg", "-nostdin", "-loglevel", "error",
+ "-f", "s16le", "-ac", "1", "-ar", "16000",
+ "-i", path,
+ "-filter:a", "loudnorm",
+ "-c:a", "libopus",
+ "-y",
+ local_outf_ogg
+ ], check = True)
+
+ # do upload
+ subprocess.run([
+ "aws", "s3", "cp", "--no-progress", local_outf_flac, s3_outf_flac
+ ], check = True)
+ subprocess.run([
+ "aws", "s3", "cp", "--no-progress", local_outf_ogg, s3_outf_ogg
+ ], check = True)
+
+ # good! now delete files including the original from local
+ os.remove(local_outf_flac)
+ os.remove(local_outf_ogg)
+ os.remove(path)
+
+ # if the directory becomes empty
+ dir_not_empty = True
+ for _ in os.scandir(dir):
+ dir_not_empty = False
+ break
+ if dir_not_empty: os.rmdir(dir)
diff --git a/src/mmfwd/recsync/__main__.py b/src/mmfwd/recsync/__main__.py
new file mode 100644
index 0000000..6f56adf
--- /dev/null
+++ b/src/mmfwd/recsync/__main__.py
@@ -0,0 +1,9 @@
+import glob
+import os
+from . import *
+
+with acquire_plock(): # flock on pid file
+ for path in glob.glob("rec/????-??"): # for each YYYY-MM dir,
+ if not os.path.isdir(path):
+ continue
+ proc_dir(path)
diff --git a/src/mmfwd/recsync/exceptions.py b/src/mmfwd/recsync/exceptions.py
new file mode 100644
index 0000000..052c48d
--- /dev/null
+++ b/src/mmfwd/recsync/exceptions.py
@@ -0,0 +1,2 @@
+class MMFWDRSException (BaseException): ...
+class MMFWDRSProcessLockException (MMFWDRSException): ...