Bind the C libraries a call actually needs
Opus, H.264, the camera and the audio devices, as C rather than as somebody's bindings to C. libmoq_ffi keeps the one piece with no C answer -- MoQ over QUIC -- and everything else now comes from libraries that have had a stable ABI for twenty years. frq.codec.opus is libopus, flat and unremarkable. PCM crosses as [pointer length] and never becomes a jolt value: a 20ms stereo frame is 1920 samples and building a vector of them fifty times a second is work with nothing to show for it. frame-size counts samples PER CHANNEL, which is the mistake worth naming -- at stereo the interleaved count is also a legal frame size, so nothing raises and the audio simply runs fast. frq.codec.h264 is openh264 one step removed, and the step is not optional. openh264's C API is a vtable: ISVCEncoder is `const ISVCEncoderVtbl*` and every method is a function pointer hanging off the object. jolt.ffi cannot call one -- Chez fixes a foreign procedure's types when it COMPILES it, and the target has to be a literal C symbol name. So c/frq_h264.c walks the vtable and exports five plain symbols. It also flattens openh264's SFrameBSInfo, whose layers and NAL counts are its business rather than ours. A hundred lines of C against a library nixpkgs already has; the contrast with the 1062-crate build it replaces is the whole point. The alternative was x264, whose C API is flat and whose licence is not ours to change. frq.capture.v4l2 is ioctls, so it names no library at all. Every VIDIOC_ request number encodes the size of the struct it carries, which makes a layout one byte wrong not a misread field but a request the kernel has never heard of -- ENOTTY for an ioctl that plainly exists. Every size and offset is therefore checked against a C program compiled on this kernel's own headers. Fifteen of fifteen agree. There is no camera in this container, so the capture path itself is unexercised and the smoke test says so rather than implying otherwise. frq.capture.alsa binds the small API: snd_pcm_set_params does in one call what hw_params does in thirty accessors, and interleaved S16 at a fixed rate is what Opus wants on one side and a device gives on the other. Reads are counted in FRAMES; an overrun is recovered rather than raised, because a dropped buffer is a thing a call survives. Exercised against ALSA's `null` PCM, which is always there and needs no hardware. Frames stay borrowed throughout, in both directions -- a captured buffer goes to the encoder as the driver's pointer and is requeued in a finally, the way a decoded one already reaches a texture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87e5be9 parent: f518938 added
c/frq_h264.c +132 -0 | new file mode 100644 | ||
| @@ -0,0 +1,132 @@ | ||
| 1 | +/* A flat C face for openh264's encoder. | |
| 2 | + * | |
| 3 | + * WHY THIS EXISTS. openh264's C API is not flat. `ISVCEncoder` is | |
| 4 | + * `const ISVCEncoderVtbl*` — a pointer to a table of function pointers — so | |
| 5 | + * calling Initialize or EncodeFrame means dereferencing the object, reading a | |
| 6 | + * slot, and calling through it. jolt.ffi cannot do that: Chez fixes a foreign | |
| 7 | + * procedure's types when it COMPILES it, and the target has to be a literal C | |
| 8 | + * symbol name rather than a function pointer (see jolt/ffi.clj, "the target | |
| 9 | + * must be a literal C symbol name"). So the vtable is walked here, in C, and | |
| 10 | + * what jolt binds is the five plain symbols below. | |
| 11 | + * | |
| 12 | + * It is deliberately thin. No policy, no buffering beyond what openh264's own | |
| 13 | + * output demands, and no decisions that belong in frq — the shim exists to | |
| 14 | + * change a calling convention, not to be a video pipeline. | |
| 15 | + * | |
| 16 | + * THE ONE THING IT DOES DO is flatten the output. openh264 hands back an | |
| 17 | + * SFrameBSInfo describing up to MAX_LAYER_NUM_OF_FRAME layers, each with its | |
| 18 | + * own NAL count and a shared bitstream buffer. A caller wanting one Annex B | |
| 19 | + * frame has to walk that; doing it in jolt would mean reading nested C structs | |
| 20 | + * whose layout is openh264's business. It is copied into one contiguous | |
| 21 | + * buffer owned by the encoder handle and handed over as a borrowed span, | |
| 22 | + * valid until the next encode — which is exactly the contract frq.av already | |
| 23 | + * has for a video frame. | |
| 24 | + */ | |
| 25 | +#include <stdlib.h> | |
| 26 | +#include <string.h> | |
| 27 | +#include <stdint.h> | |
| 28 | +#include <wels/codec_api.h> | |
| 29 | +#include <wels/codec_app_def.h> | |
| 30 | + | |
| 31 | +typedef struct { | |
| 32 | + ISVCEncoder *enc; | |
| 33 | + unsigned char *out; /* flattened Annex B, grown as needed */ | |
| 34 | + size_t out_cap; | |
| 35 | + int width, height; | |
| 36 | +} frq_h264; | |
| 37 | + | |
| 38 | +/* Answers 0 on success, or openh264's own non-zero return. */ | |
| 39 | +int frq_h264_open(int width, int height, int fps, int bitrate, void **handle) { | |
| 40 | + ISVCEncoder *enc = NULL; | |
| 41 | + frq_h264 *h; | |
| 42 | + SEncParamBase p; | |
| 43 | + int rc; | |
| 44 | + | |
| 45 | + *handle = NULL; | |
| 46 | + rc = WelsCreateSVCEncoder(&enc); | |
| 47 | + if (rc != 0 || enc == NULL) return rc ? rc : -1; | |
| 48 | + | |
| 49 | + memset(&p, 0, sizeof(p)); | |
| 50 | + p.iUsageType = CAMERA_VIDEO_REAL_TIME; | |
| 51 | + p.iPicWidth = width; | |
| 52 | + p.iPicHeight = height; | |
| 53 | + p.iTargetBitrate = bitrate; | |
| 54 | + p.fMaxFrameRate = (float)fps; | |
| 55 | + | |
| 56 | + rc = (*enc)->Initialize(enc, &p); | |
| 57 | + if (rc != 0) { WelsDestroySVCEncoder(enc); return rc; } | |
| 58 | + | |
| 59 | + h = (frq_h264 *)calloc(1, sizeof(frq_h264)); | |
| 60 | + if (h == NULL) { (*enc)->Uninitialize(enc); WelsDestroySVCEncoder(enc); return -1; } | |
| 61 | + h->enc = enc; h->width = width; h->height = height; | |
| 62 | + *handle = h; | |
| 63 | + return 0; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/* Encode one I420 frame. | |
| 67 | + * | |
| 68 | + * `i420` is width*height luma followed by two (width/2)*(height/2) planes. | |
| 69 | + * On success answers 0 and sets *out / *out_len to a BORROWED span, valid | |
| 70 | + * until the next call on this handle. *keyframe says whether it is an IDR. | |
| 71 | + * A frame openh264 chose to skip answers 0 with *out_len == 0. */ | |
| 72 | +int frq_h264_encode(void *handle, const unsigned char *i420, long long pts_us, | |
| 73 | + const unsigned char **out, int *out_len, int *keyframe) { | |
| 74 | + frq_h264 *h = (frq_h264 *)handle; | |
| 75 | + SSourcePicture pic; | |
| 76 | + SFrameBSInfo info; | |
| 77 | + int rc, i, j, total = 0, off = 0; | |
| 78 | + | |
| 79 | + *out = NULL; *out_len = 0; *keyframe = 0; | |
| 80 | + | |
| 81 | + memset(&pic, 0, sizeof(pic)); | |
| 82 | + pic.iPicWidth = h->width; | |
| 83 | + pic.iPicHeight = h->height; | |
| 84 | + pic.iColorFormat = videoFormatI420; | |
| 85 | + pic.iStride[0] = h->width; | |
| 86 | + pic.iStride[1] = h->width / 2; | |
| 87 | + pic.iStride[2] = h->width / 2; | |
| 88 | + pic.pData[0] = (unsigned char *)i420; | |
| 89 | + pic.pData[1] = pic.pData[0] + h->width * h->height; | |
| 90 | + pic.pData[2] = pic.pData[1] + (h->width / 2) * (h->height / 2); | |
| 91 | + pic.uiTimeStamp = pts_us / 1000; /* openh264 counts milliseconds */ | |
| 92 | + | |
| 93 | + memset(&info, 0, sizeof(info)); | |
| 94 | + rc = (*h->enc)->EncodeFrame(h->enc, &pic, &info); | |
| 95 | + if (rc != cmResultSuccess) return rc; | |
| 96 | + if (info.eFrameType == videoFrameTypeSkip) return 0; | |
| 97 | + | |
| 98 | + for (i = 0; i < info.iLayerNum; i++) | |
| 99 | + for (j = 0; j < info.sLayerInfo[i].iNalCount; j++) | |
| 100 | + total += info.sLayerInfo[i].pNalLengthInByte[j]; | |
| 101 | + | |
| 102 | + if ((size_t)total > h->out_cap) { | |
| 103 | + unsigned char *grown = (unsigned char *)realloc(h->out, (size_t)total); | |
| 104 | + if (grown == NULL) return -1; | |
| 105 | + h->out = grown; h->out_cap = (size_t)total; | |
| 106 | + } | |
| 107 | + for (i = 0; i < info.iLayerNum; i++) { | |
| 108 | + int n = 0, k; | |
| 109 | + for (k = 0; k < info.sLayerInfo[i].iNalCount; k++) | |
| 110 | + n += info.sLayerInfo[i].pNalLengthInByte[k]; | |
| 111 | + memcpy(h->out + off, info.sLayerInfo[i].pBsBuf, (size_t)n); | |
| 112 | + off += n; | |
| 113 | + } | |
| 114 | + | |
| 115 | + *out = h->out; | |
| 116 | + *out_len = total; | |
| 117 | + *keyframe = (info.eFrameType == videoFrameTypeIDR); | |
| 118 | + return 0; | |
| 119 | +} | |
| 120 | + | |
| 121 | +int frq_h264_force_keyframe(void *handle) { | |
| 122 | + frq_h264 *h = (frq_h264 *)handle; | |
| 123 | + return (*h->enc)->ForceIntraFrame(h->enc, true); | |
| 124 | +} | |
| 125 | + | |
| 126 | +void frq_h264_close(void *handle) { | |
| 127 | + frq_h264 *h = (frq_h264 *)handle; | |
| 128 | + if (h == NULL) return; | |
| 129 | + if (h->enc) { (*h->enc)->Uninitialize(h->enc); WelsDestroySVCEncoder(h->enc); } | |
| 130 | + free(h->out); | |
| 131 | + free(h); | |
| 132 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,132 @@ | |||
| 1 | +/* A flat C face for openh264's encoder. | ||
| 2 | + * | ||
| 3 | + * WHY THIS EXISTS. openh264's C API is not flat. `ISVCEncoder` is | ||
| 4 | + * `const ISVCEncoderVtbl*` — a pointer to a table of function pointers — so | ||
| 5 | + * calling Initialize or EncodeFrame means dereferencing the object, reading a | ||
| 6 | + * slot, and calling through it. jolt.ffi cannot do that: Chez fixes a foreign | ||
| 7 | + * procedure's types when it COMPILES it, and the target has to be a literal C | ||
| 8 | + * symbol name rather than a function pointer (see jolt/ffi.clj, "the target | ||
| 9 | + * must be a literal C symbol name"). So the vtable is walked here, in C, and | ||
| 10 | + * what jolt binds is the five plain symbols below. | ||
| 11 | + * | ||
| 12 | + * It is deliberately thin. No policy, no buffering beyond what openh264's own | ||
| 13 | + * output demands, and no decisions that belong in frq — the shim exists to | ||
| 14 | + * change a calling convention, not to be a video pipeline. | ||
| 15 | + * | ||
| 16 | + * THE ONE THING IT DOES DO is flatten the output. openh264 hands back an | ||
| 17 | + * SFrameBSInfo describing up to MAX_LAYER_NUM_OF_FRAME layers, each with its | ||
| 18 | + * own NAL count and a shared bitstream buffer. A caller wanting one Annex B | ||
| 19 | + * frame has to walk that; doing it in jolt would mean reading nested C structs | ||
| 20 | + * whose layout is openh264's business. It is copied into one contiguous | ||
| 21 | + * buffer owned by the encoder handle and handed over as a borrowed span, | ||
| 22 | + * valid until the next encode — which is exactly the contract frq.av already | ||
| 23 | + * has for a video frame. | ||
| 24 | + */ | ||
| 25 | +#include <stdlib.h> | ||
| 26 | +#include <string.h> | ||
| 27 | +#include <stdint.h> | ||
| 28 | +#include <wels/codec_api.h> | ||
| 29 | +#include <wels/codec_app_def.h> | ||
| 30 | + | ||
| 31 | +typedef struct { | ||
| 32 | + ISVCEncoder *enc; | ||
| 33 | + unsigned char *out; /* flattened Annex B, grown as needed */ | ||
| 34 | + size_t out_cap; | ||
| 35 | + int width, height; | ||
| 36 | +} frq_h264; | ||
| 37 | + | ||
| 38 | +/* Answers 0 on success, or openh264's own non-zero return. */ | ||
| 39 | +int frq_h264_open(int width, int height, int fps, int bitrate, void **handle) { | ||
| 40 | + ISVCEncoder *enc = NULL; | ||
| 41 | + frq_h264 *h; | ||
| 42 | + SEncParamBase p; | ||
| 43 | + int rc; | ||
| 44 | + | ||
| 45 | + *handle = NULL; | ||
| 46 | + rc = WelsCreateSVCEncoder(&enc); | ||
| 47 | + if (rc != 0 || enc == NULL) return rc ? rc : -1; | ||
| 48 | + | ||
| 49 | + memset(&p, 0, sizeof(p)); | ||
| 50 | + p.iUsageType = CAMERA_VIDEO_REAL_TIME; | ||
| 51 | + p.iPicWidth = width; | ||
| 52 | + p.iPicHeight = height; | ||
| 53 | + p.iTargetBitrate = bitrate; | ||
| 54 | + p.fMaxFrameRate = (float)fps; | ||
| 55 | + | ||
| 56 | + rc = (*enc)->Initialize(enc, &p); | ||
| 57 | + if (rc != 0) { WelsDestroySVCEncoder(enc); return rc; } | ||
| 58 | + | ||
| 59 | + h = (frq_h264 *)calloc(1, sizeof(frq_h264)); | ||
| 60 | + if (h == NULL) { (*enc)->Uninitialize(enc); WelsDestroySVCEncoder(enc); return -1; } | ||
| 61 | + h->enc = enc; h->width = width; h->height = height; | ||
| 62 | + *handle = h; | ||
| 63 | + return 0; | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +/* Encode one I420 frame. | ||
| 67 | + * | ||
| 68 | + * `i420` is width*height luma followed by two (width/2)*(height/2) planes. | ||
| 69 | + * On success answers 0 and sets *out / *out_len to a BORROWED span, valid | ||
| 70 | + * until the next call on this handle. *keyframe says whether it is an IDR. | ||
| 71 | + * A frame openh264 chose to skip answers 0 with *out_len == 0. */ | ||
| 72 | +int frq_h264_encode(void *handle, const unsigned char *i420, long long pts_us, | ||
| 73 | + const unsigned char **out, int *out_len, int *keyframe) { | ||
| 74 | + frq_h264 *h = (frq_h264 *)handle; | ||
| 75 | + SSourcePicture pic; | ||
| 76 | + SFrameBSInfo info; | ||
| 77 | + int rc, i, j, total = 0, off = 0; | ||
| 78 | + | ||
| 79 | + *out = NULL; *out_len = 0; *keyframe = 0; | ||
| 80 | + | ||
| 81 | + memset(&pic, 0, sizeof(pic)); | ||
| 82 | + pic.iPicWidth = h->width; | ||
| 83 | + pic.iPicHeight = h->height; | ||
| 84 | + pic.iColorFormat = videoFormatI420; | ||
| 85 | + pic.iStride[0] = h->width; | ||
| 86 | + pic.iStride[1] = h->width / 2; | ||
| 87 | + pic.iStride[2] = h->width / 2; | ||
| 88 | + pic.pData[0] = (unsigned char *)i420; | ||
| 89 | + pic.pData[1] = pic.pData[0] + h->width * h->height; | ||
| 90 | + pic.pData[2] = pic.pData[1] + (h->width / 2) * (h->height / 2); | ||
| 91 | + pic.uiTimeStamp = pts_us / 1000; /* openh264 counts milliseconds */ | ||
| 92 | + | ||
| 93 | + memset(&info, 0, sizeof(info)); | ||
| 94 | + rc = (*h->enc)->EncodeFrame(h->enc, &pic, &info); | ||
| 95 | + if (rc != cmResultSuccess) return rc; | ||
| 96 | + if (info.eFrameType == videoFrameTypeSkip) return 0; | ||
| 97 | + | ||
| 98 | + for (i = 0; i < info.iLayerNum; i++) | ||
| 99 | + for (j = 0; j < info.sLayerInfo[i].iNalCount; j++) | ||
| 100 | + total += info.sLayerInfo[i].pNalLengthInByte[j]; | ||
| 101 | + | ||
| 102 | + if ((size_t)total > h->out_cap) { | ||
| 103 | + unsigned char *grown = (unsigned char *)realloc(h->out, (size_t)total); | ||
| 104 | + if (grown == NULL) return -1; | ||
| 105 | + h->out = grown; h->out_cap = (size_t)total; | ||
| 106 | + } | ||
| 107 | + for (i = 0; i < info.iLayerNum; i++) { | ||
| 108 | + int n = 0, k; | ||
| 109 | + for (k = 0; k < info.sLayerInfo[i].iNalCount; k++) | ||
| 110 | + n += info.sLayerInfo[i].pNalLengthInByte[k]; | ||
| 111 | + memcpy(h->out + off, info.sLayerInfo[i].pBsBuf, (size_t)n); | ||
| 112 | + off += n; | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + *out = h->out; | ||
| 116 | + *out_len = total; | ||
| 117 | + *keyframe = (info.eFrameType == videoFrameTypeIDR); | ||
| 118 | + return 0; | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +int frq_h264_force_keyframe(void *handle) { | ||
| 122 | + frq_h264 *h = (frq_h264 *)handle; | ||
| 123 | + return (*h->enc)->ForceIntraFrame(h->enc, true); | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +void frq_h264_close(void *handle) { | ||
| 127 | + frq_h264 *h = (frq_h264 *)handle; | ||
| 128 | + if (h == NULL) return; | ||
| 129 | + if (h->enc) { (*h->enc)->Uninitialize(h->enc); WelsDestroySVCEncoder(h->enc); } | ||
| 130 | + free(h->out); | ||
| 131 | + free(h); | ||
| 132 | +} | ||
modified
deps.edn +32 -1 | @@ -51,7 +51,38 @@ | ||
| 51 | 51 | ;; No :darwin: :systems is x86_64-linux and aarch64-linux, so |
| 52 | 52 | ;; a .dylib named here would name a file nothing fetches. |
| 53 | 53 | {:name "moq_ffi" |
| 54 | - :linux ["libmoq_ffi.so"]}] | |
| 54 | + :linux ["libmoq_ffi.so"]} | |
| 55 | + | |
| 56 | + ;; The codecs, as C libraries rather than as somebody's | |
| 57 | + ;; bindings to them. Opus first; openh264 beside it when the | |
| 58 | + ;; video half lands. | |
| 59 | + ;; | |
| 60 | + ;; The SONAME, not the bare .so: a `libopus.so` is the -dev | |
| 61 | + ;; symlink and is not what a runtime closure carries. The | |
| 62 | + ;; loader looks these up by name in one directory, which the | |
| 63 | + ;; flake's `nativeAll` is. | |
| 64 | + {:name "opus" | |
| 65 | + :linux ["libopus.so.0"] | |
| 66 | + :darwin ["libopus.0.dylib"]} | |
| 67 | + | |
| 68 | + ;; H.264, one step removed. openh264's C API is a vtable — | |
| 69 | + ;; `ISVCEncoder` is `const ISVCEncoderVtbl*` — and jolt.ffi | |
| 70 | + ;; can only call a literal C symbol, never a function | |
| 71 | + ;; pointer. c/frq_h264.c walks the vtable and exports flat | |
| 72 | + ;; symbols; this is that. libopenh264 itself comes along as | |
| 73 | + ;; its DT_NEEDED and is never named here. | |
| 74 | + {:name "frqh264" | |
| 75 | + :linux ["libfrqh264.so"] | |
| 76 | + :darwin ["libfrqh264.dylib"]} | |
| 77 | + | |
| 78 | + ;; Audio devices. No :darwin — CoreAudio is the other side of | |
| 79 | + ;; that door and is not this library. | |
| 80 | + ;; | |
| 81 | + ;; V4L2 is deliberately absent from this list: the camera is | |
| 82 | + ;; ioctls against libc and the kernel, so there is nothing to | |
| 83 | + ;; load. See frq.capture.v4l2. | |
| 84 | + {:name "asound" | |
| 85 | + :linux ["libasound.so.2"]}] | |
| 55 | 86 | |
| 56 | 87 | :aliases {:frq {:main-opts ["-m" "frq.app"]} |
| 57 | 88 | |
| @@ -51,7 +51,38 @@ | |||
| 51 | ;; No :darwin: :systems is x86_64-linux and aarch64-linux, so | 51 | ;; No :darwin: :systems is x86_64-linux and aarch64-linux, so |
| 52 | ;; a .dylib named here would name a file nothing fetches. | 52 | ;; a .dylib named here would name a file nothing fetches. |
| 53 | {:name "moq_ffi" | 53 | {:name "moq_ffi" |
| 54 | - :linux ["libmoq_ffi.so"]}] | 54 | + :linux ["libmoq_ffi.so"]} |
| 55 | + | ||
| 56 | + ;; The codecs, as C libraries rather than as somebody's | ||
| 57 | + ;; bindings to them. Opus first; openh264 beside it when the | ||
| 58 | + ;; video half lands. | ||
| 59 | + ;; | ||
| 60 | + ;; The SONAME, not the bare .so: a `libopus.so` is the -dev | ||
| 61 | + ;; symlink and is not what a runtime closure carries. The | ||
| 62 | + ;; loader looks these up by name in one directory, which the | ||
| 63 | + ;; flake's `nativeAll` is. | ||
| 64 | + {:name "opus" | ||
| 65 | + :linux ["libopus.so.0"] | ||
| 66 | + :darwin ["libopus.0.dylib"]} | ||
| 67 | + | ||
| 68 | + ;; H.264, one step removed. openh264's C API is a vtable — | ||
| 69 | + ;; `ISVCEncoder` is `const ISVCEncoderVtbl*` — and jolt.ffi | ||
| 70 | + ;; can only call a literal C symbol, never a function | ||
| 71 | + ;; pointer. c/frq_h264.c walks the vtable and exports flat | ||
| 72 | + ;; symbols; this is that. libopenh264 itself comes along as | ||
| 73 | + ;; its DT_NEEDED and is never named here. | ||
| 74 | + {:name "frqh264" | ||
| 75 | + :linux ["libfrqh264.so"] | ||
| 76 | + :darwin ["libfrqh264.dylib"]} | ||
| 77 | + | ||
| 78 | + ;; Audio devices. No :darwin — CoreAudio is the other side of | ||
| 79 | + ;; that door and is not this library. | ||
| 80 | + ;; | ||
| 81 | + ;; V4L2 is deliberately absent from this list: the camera is | ||
| 82 | + ;; ioctls against libc and the kernel, so there is nothing to | ||
| 83 | + ;; load. See frq.capture.v4l2. | ||
| 84 | + {:name "asound" | ||
| 85 | + :linux ["libasound.so.2"]}] | ||
| 55 | 86 | ||
| 56 | :aliases {:frq {:main-opts ["-m" "frq.app"]} | 87 | :aliases {:frq {:main-opts ["-m" "frq.app"]} |
| 57 | 88 | ||
modified
flake.nix +41 -2 | @@ -240,9 +240,48 @@ | ||
| 240 | 240 | # from two places — jolt-native's flake, and the moq-ffi release — so |
| 241 | 241 | # they are joined rather than the path being made a list, which the |
| 242 | 242 | # loader does not take. |
| 243 | + # The C codecs, from nixpkgs. libmoq_ffi carries the transport and | |
| 244 | + # nothing else — moq-ffi's `audio` and `video` features would have | |
| 245 | + # brought Opus and H.264 with them, at the price of compiling a | |
| 246 | + # 1062-crate workspace — so the codecs are linked here instead, | |
| 247 | + # where they have always lived. | |
| 248 | + # | |
| 249 | + # Named in :jolt/native, so the loader resolves them the same way it | |
| 250 | + # resolves libvidya: by name, out of one directory. | |
| 251 | + # A flat C face for openh264, because openh264 has none. Its | |
| 252 | + # `ISVCEncoder` is `const ISVCEncoderVtbl*` — every method is a | |
| 253 | + # function pointer in a vtable — and jolt.ffi cannot call one: Chez | |
| 254 | + # fixes a foreign procedure's types when it compiles it, and the | |
| 255 | + # target must be a literal C symbol name. So the vtable is walked in | |
| 256 | + # c/frq_h264.c and jolt binds the five plain symbols it exports. | |
| 257 | + # | |
| 258 | + # One translation unit against a library nixpkgs already has. It is | |
| 259 | + # a calling convention adapter, not a second media plane, and the | |
| 260 | + # distinction from the moq-ffi build it replaces is the whole point: | |
| 261 | + # this compiles one .c file, not a 1062-crate workspace. | |
| 262 | + frqH264 = pkgs.stdenv.mkDerivation { | |
| 263 | + pname = "frq-h264"; | |
| 264 | + version = "0.1"; | |
| 265 | + src = ./c; | |
| 266 | + nativeBuildInputs = [ pkgs.pkg-config ]; | |
| 267 | + buildInputs = [ pkgs.openh264 ]; | |
| 268 | + buildPhase = '' | |
| 269 | + $CC -O2 -fPIC -shared frq_h264.c -o libfrqh264.so \ | |
| 270 | + $(pkg-config --cflags --libs openh264) | |
| 271 | + ''; | |
| 272 | + installPhase = '' | |
| 273 | + mkdir -p $out/lib && cp libfrqh264.so $out/lib/ | |
| 274 | + ''; | |
| 275 | + }; | |
| 276 | + | |
| 277 | + # openh264 is here for frqH264's DT_NEEDED; alsa-lib for capture | |
| 278 | + # and playback. V4L2 needs nothing: it is ioctls against libc and | |
| 279 | + # the kernel, so there is no library to name. | |
| 280 | + codecs = [ pkgs.libopus pkgs.openh264 frqH264 pkgs.alsa-lib ]; | |
| 281 | + | |
| 243 | 282 | nativeAll = pkgs.symlinkJoin { |
| 244 | 283 | name = "frq-native"; |
| 245 | - paths = [ native moqFfi ]; | |
| 284 | + paths = [ native moqFfi ] ++ codecs; | |
| 246 | 285 | }; |
| 247 | 286 | |
| 248 | 287 | # Jolt itself: Clojure on Chez, built the way its own flake builds it. |
| @@ -397,7 +436,7 @@ | ||
| 397 | 436 | }; |
| 398 | 437 | in |
| 399 | 438 | { |
| 400 | - inherit native moqFfi nativeAll frq; | |
| 439 | + inherit native moqFfi frqH264 nativeAll frq; | |
| 401 | 440 | inherit tui; |
| 402 | 441 | jolt = joltRuntime; |
| 403 | 442 | default = frq; |
| @@ -240,9 +240,48 @@ | |||
| 240 | # from two places — jolt-native's flake, and the moq-ffi release — so | 240 | # from two places — jolt-native's flake, and the moq-ffi release — so |
| 241 | # they are joined rather than the path being made a list, which the | 241 | # they are joined rather than the path being made a list, which the |
| 242 | # loader does not take. | 242 | # loader does not take. |
| 243 | + # The C codecs, from nixpkgs. libmoq_ffi carries the transport and | ||
| 244 | + # nothing else — moq-ffi's `audio` and `video` features would have | ||
| 245 | + # brought Opus and H.264 with them, at the price of compiling a | ||
| 246 | + # 1062-crate workspace — so the codecs are linked here instead, | ||
| 247 | + # where they have always lived. | ||
| 248 | + # | ||
| 249 | + # Named in :jolt/native, so the loader resolves them the same way it | ||
| 250 | + # resolves libvidya: by name, out of one directory. | ||
| 251 | + # A flat C face for openh264, because openh264 has none. Its | ||
| 252 | + # `ISVCEncoder` is `const ISVCEncoderVtbl*` — every method is a | ||
| 253 | + # function pointer in a vtable — and jolt.ffi cannot call one: Chez | ||
| 254 | + # fixes a foreign procedure's types when it compiles it, and the | ||
| 255 | + # target must be a literal C symbol name. So the vtable is walked in | ||
| 256 | + # c/frq_h264.c and jolt binds the five plain symbols it exports. | ||
| 257 | + # | ||
| 258 | + # One translation unit against a library nixpkgs already has. It is | ||
| 259 | + # a calling convention adapter, not a second media plane, and the | ||
| 260 | + # distinction from the moq-ffi build it replaces is the whole point: | ||
| 261 | + # this compiles one .c file, not a 1062-crate workspace. | ||
| 262 | + frqH264 = pkgs.stdenv.mkDerivation { | ||
| 263 | + pname = "frq-h264"; | ||
| 264 | + version = "0.1"; | ||
| 265 | + src = ./c; | ||
| 266 | + nativeBuildInputs = [ pkgs.pkg-config ]; | ||
| 267 | + buildInputs = [ pkgs.openh264 ]; | ||
| 268 | + buildPhase = '' | ||
| 269 | + $CC -O2 -fPIC -shared frq_h264.c -o libfrqh264.so \ | ||
| 270 | + $(pkg-config --cflags --libs openh264) | ||
| 271 | + ''; | ||
| 272 | + installPhase = '' | ||
| 273 | + mkdir -p $out/lib && cp libfrqh264.so $out/lib/ | ||
| 274 | + ''; | ||
| 275 | + }; | ||
| 276 | + | ||
| 277 | + # openh264 is here for frqH264's DT_NEEDED; alsa-lib for capture | ||
| 278 | + # and playback. V4L2 needs nothing: it is ioctls against libc and | ||
| 279 | + # the kernel, so there is no library to name. | ||
| 280 | + codecs = [ pkgs.libopus pkgs.openh264 frqH264 pkgs.alsa-lib ]; | ||
| 281 | + | ||
| 243 | nativeAll = pkgs.symlinkJoin { | 282 | nativeAll = pkgs.symlinkJoin { |
| 244 | name = "frq-native"; | 283 | name = "frq-native"; |
| 245 | - paths = [ native moqFfi ]; | 284 | + paths = [ native moqFfi ] ++ codecs; |
| 246 | }; | 285 | }; |
| 247 | 286 | ||
| 248 | # Jolt itself: Clojure on Chez, built the way its own flake builds it. | 287 | # Jolt itself: Clojure on Chez, built the way its own flake builds it. |
| @@ -397,7 +436,7 @@ | |||
| 397 | }; | 436 | }; |
| 398 | in | 437 | in |
| 399 | { | 438 | { |
| 400 | - inherit native moqFfi nativeAll frq; | 439 | + inherit native moqFfi frqH264 nativeAll frq; |
| 401 | inherit tui; | 440 | inherit tui; |
| 402 | jolt = joltRuntime; | 441 | jolt = joltRuntime; |
| 403 | default = frq; | 442 | default = frq; |
added
src/frq/capture/alsa.clj +88 -0 | new file mode 100644 | ||
| @@ -0,0 +1,88 @@ | ||
| 1 | +(ns frq.capture.alsa | |
| 2 | + "Audio devices, through libasound. | |
| 3 | + | |
| 4 | + The last of the four C libraries the media plane needs, and the least | |
| 5 | + eventful: ALSA has a flat C API, no vtable and no ioctl arithmetic. What it | |
| 6 | + does have is two APIs, and this binds the small one — `snd_pcm_set_params` | |
| 7 | + configures format, access, channels, rate, resampling and latency in a | |
| 8 | + single call, where the general path is a `snd_pcm_hw_params_t` allocated by | |
| 9 | + the library and poked field by field through thirty accessors. The small one | |
| 10 | + is enough for a call: interleaved S16 at a fixed rate is what Opus wants on | |
| 11 | + one side and what a device gives on the other. | |
| 12 | + | |
| 13 | + READS ARE BLOCKING and counted in FRAMES, not bytes and not samples. A | |
| 14 | + frame is one sample per channel, so 960 frames of stereo S16 is 3840 bytes; | |
| 15 | + passing a byte count asks for four times the audio and blocks for four | |
| 16 | + times as long, which looks like a slow device rather than a bug. | |
| 17 | + | |
| 18 | + RECOVERY IS EXPECTED. An overrun on capture is normal on a busy machine and | |
| 19 | + is not a failure — `snd_pcm_recover` puts the stream back and the next read | |
| 20 | + continues. `read!` does that itself and reports the loss rather than | |
| 21 | + raising, because a dropped buffer is a thing a call survives." | |
| 22 | + (:require [jolt.ffi :as ffi])) | |
| 23 | + | |
| 24 | +(def ^:const format-s16-le 2) | |
| 25 | +(def ^:const access-rw-interleaved 3) | |
| 26 | +(def streams {:playback 0 :capture 1}) | |
| 27 | + | |
| 28 | +(ffi/defcfn raw-open "snd_pcm_open" [:pointer :string :int :int] :int) | |
| 29 | +(ffi/defcfn raw-close "snd_pcm_close" [:pointer] :int) | |
| 30 | +(ffi/defcfn raw-set-params "snd_pcm_set_params" | |
| 31 | + [:pointer :int :int :uint :uint :int :uint] :int) | |
| 32 | +(ffi/defcfn raw-readi "snd_pcm_readi" [:pointer :pointer :uint64] :int64) | |
| 33 | +(ffi/defcfn raw-writei "snd_pcm_writei" [:pointer :pointer :uint64] :int64) | |
| 34 | +(ffi/defcfn raw-prepare "snd_pcm_prepare" [:pointer] :int) | |
| 35 | +(ffi/defcfn raw-recover "snd_pcm_recover" [:pointer :int :int] :int) | |
| 36 | +(ffi/defcfn raw-drain "snd_pcm_drain" [:pointer] :int) | |
| 37 | +(ffi/defcfn strerror "snd_strerror" [:int] :string) | |
| 38 | + | |
| 39 | +(defn- check! [rc what] | |
| 40 | + (if (neg? rc) | |
| 41 | + (throw (ex-info (str "alsa: " what ": " (strerror rc)) {:code rc :op what})) | |
| 42 | + rc)) | |
| 43 | + | |
| 44 | +(defn open-pcm | |
| 45 | + "Open a PCM by ALSA name — \"default\", \"hw:1,0\", or \"null\" for a device | |
| 46 | + that swallows everything and always exists. | |
| 47 | + | |
| 48 | + `latency-us` is what ALSA is asked to aim for; it picks buffer and period | |
| 49 | + sizes to suit and may not hit it exactly." | |
| 50 | + [name stream {:keys [rate channels latency-us] | |
| 51 | + :or {rate 48000 channels 1 latency-us 20000}}] | |
| 52 | + (ffi/with-arena [a] | |
| 53 | + (let [out (ffi/alloc a 8) | |
| 54 | + dir (or (streams stream) | |
| 55 | + (throw (ex-info "unknown pcm stream" {:got stream})))] | |
| 56 | + (check! (raw-open out name dir 0) (str "open " name)) | |
| 57 | + (let [pcm (ffi/read out :pointer)] | |
| 58 | + (check! (raw-set-params pcm format-s16-le access-rw-interleaved | |
| 59 | + channels rate 1 latency-us) | |
| 60 | + "set_params") | |
| 61 | + pcm)))) | |
| 62 | + | |
| 63 | +(defn read! | |
| 64 | + "Read up to `frames` frames of interleaved S16 into `buf`. | |
| 65 | + | |
| 66 | + Answers {:frames n} on a normal read, or {:frames n :recovered true} when | |
| 67 | + an overrun was absorbed. Frames, not bytes — see the namespace docstring." | |
| 68 | + [pcm buf frames] | |
| 69 | + (let [n (raw-readi pcm buf frames)] | |
| 70 | + (if (neg? n) | |
| 71 | + (do (check! (raw-recover pcm n 1) "recover") | |
| 72 | + (let [n2 (raw-readi pcm buf frames)] | |
| 73 | + {:frames (max 0 (check! n2 "readi")) :recovered true})) | |
| 74 | + {:frames n}))) | |
| 75 | + | |
| 76 | +(defn write! | |
| 77 | + "Write `frames` frames of interleaved S16 from `buf`." | |
| 78 | + [pcm buf frames] | |
| 79 | + (let [n (raw-writei pcm buf frames)] | |
| 80 | + (if (neg? n) | |
| 81 | + (do (check! (raw-recover pcm n 1) "recover") | |
| 82 | + {:frames (max 0 (check! (raw-writei pcm buf frames) "writei")) | |
| 83 | + :recovered true}) | |
| 84 | + {:frames n}))) | |
| 85 | + | |
| 86 | +(defn prepare! [pcm] (check! (raw-prepare pcm) "prepare") nil) | |
| 87 | +(defn drain! [pcm] (check! (raw-drain pcm) "drain") nil) | |
| 88 | +(defn close! [pcm] (raw-close pcm) nil) | |
| new file mode 100644 | |||
| @@ -0,0 +1,88 @@ | |||
| 1 | +(ns frq.capture.alsa | ||
| 2 | + "Audio devices, through libasound. | ||
| 3 | + | ||
| 4 | + The last of the four C libraries the media plane needs, and the least | ||
| 5 | + eventful: ALSA has a flat C API, no vtable and no ioctl arithmetic. What it | ||
| 6 | + does have is two APIs, and this binds the small one — `snd_pcm_set_params` | ||
| 7 | + configures format, access, channels, rate, resampling and latency in a | ||
| 8 | + single call, where the general path is a `snd_pcm_hw_params_t` allocated by | ||
| 9 | + the library and poked field by field through thirty accessors. The small one | ||
| 10 | + is enough for a call: interleaved S16 at a fixed rate is what Opus wants on | ||
| 11 | + one side and what a device gives on the other. | ||
| 12 | + | ||
| 13 | + READS ARE BLOCKING and counted in FRAMES, not bytes and not samples. A | ||
| 14 | + frame is one sample per channel, so 960 frames of stereo S16 is 3840 bytes; | ||
| 15 | + passing a byte count asks for four times the audio and blocks for four | ||
| 16 | + times as long, which looks like a slow device rather than a bug. | ||
| 17 | + | ||
| 18 | + RECOVERY IS EXPECTED. An overrun on capture is normal on a busy machine and | ||
| 19 | + is not a failure — `snd_pcm_recover` puts the stream back and the next read | ||
| 20 | + continues. `read!` does that itself and reports the loss rather than | ||
| 21 | + raising, because a dropped buffer is a thing a call survives." | ||
| 22 | + (:require [jolt.ffi :as ffi])) | ||
| 23 | + | ||
| 24 | +(def ^:const format-s16-le 2) | ||
| 25 | +(def ^:const access-rw-interleaved 3) | ||
| 26 | +(def streams {:playback 0 :capture 1}) | ||
| 27 | + | ||
| 28 | +(ffi/defcfn raw-open "snd_pcm_open" [:pointer :string :int :int] :int) | ||
| 29 | +(ffi/defcfn raw-close "snd_pcm_close" [:pointer] :int) | ||
| 30 | +(ffi/defcfn raw-set-params "snd_pcm_set_params" | ||
| 31 | + [:pointer :int :int :uint :uint :int :uint] :int) | ||
| 32 | +(ffi/defcfn raw-readi "snd_pcm_readi" [:pointer :pointer :uint64] :int64) | ||
| 33 | +(ffi/defcfn raw-writei "snd_pcm_writei" [:pointer :pointer :uint64] :int64) | ||
| 34 | +(ffi/defcfn raw-prepare "snd_pcm_prepare" [:pointer] :int) | ||
| 35 | +(ffi/defcfn raw-recover "snd_pcm_recover" [:pointer :int :int] :int) | ||
| 36 | +(ffi/defcfn raw-drain "snd_pcm_drain" [:pointer] :int) | ||
| 37 | +(ffi/defcfn strerror "snd_strerror" [:int] :string) | ||
| 38 | + | ||
| 39 | +(defn- check! [rc what] | ||
| 40 | + (if (neg? rc) | ||
| 41 | + (throw (ex-info (str "alsa: " what ": " (strerror rc)) {:code rc :op what})) | ||
| 42 | + rc)) | ||
| 43 | + | ||
| 44 | +(defn open-pcm | ||
| 45 | + "Open a PCM by ALSA name — \"default\", \"hw:1,0\", or \"null\" for a device | ||
| 46 | + that swallows everything and always exists. | ||
| 47 | + | ||
| 48 | + `latency-us` is what ALSA is asked to aim for; it picks buffer and period | ||
| 49 | + sizes to suit and may not hit it exactly." | ||
| 50 | + [name stream {:keys [rate channels latency-us] | ||
| 51 | + :or {rate 48000 channels 1 latency-us 20000}}] | ||
| 52 | + (ffi/with-arena [a] | ||
| 53 | + (let [out (ffi/alloc a 8) | ||
| 54 | + dir (or (streams stream) | ||
| 55 | + (throw (ex-info "unknown pcm stream" {:got stream})))] | ||
| 56 | + (check! (raw-open out name dir 0) (str "open " name)) | ||
| 57 | + (let [pcm (ffi/read out :pointer)] | ||
| 58 | + (check! (raw-set-params pcm format-s16-le access-rw-interleaved | ||
| 59 | + channels rate 1 latency-us) | ||
| 60 | + "set_params") | ||
| 61 | + pcm)))) | ||
| 62 | + | ||
| 63 | +(defn read! | ||
| 64 | + "Read up to `frames` frames of interleaved S16 into `buf`. | ||
| 65 | + | ||
| 66 | + Answers {:frames n} on a normal read, or {:frames n :recovered true} when | ||
| 67 | + an overrun was absorbed. Frames, not bytes — see the namespace docstring." | ||
| 68 | + [pcm buf frames] | ||
| 69 | + (let [n (raw-readi pcm buf frames)] | ||
| 70 | + (if (neg? n) | ||
| 71 | + (do (check! (raw-recover pcm n 1) "recover") | ||
| 72 | + (let [n2 (raw-readi pcm buf frames)] | ||
| 73 | + {:frames (max 0 (check! n2 "readi")) :recovered true})) | ||
| 74 | + {:frames n}))) | ||
| 75 | + | ||
| 76 | +(defn write! | ||
| 77 | + "Write `frames` frames of interleaved S16 from `buf`." | ||
| 78 | + [pcm buf frames] | ||
| 79 | + (let [n (raw-writei pcm buf frames)] | ||
| 80 | + (if (neg? n) | ||
| 81 | + (do (check! (raw-recover pcm n 1) "recover") | ||
| 82 | + {:frames (max 0 (check! (raw-writei pcm buf frames) "writei")) | ||
| 83 | + :recovered true}) | ||
| 84 | + {:frames n}))) | ||
| 85 | + | ||
| 86 | +(defn prepare! [pcm] (check! (raw-prepare pcm) "prepare") nil) | ||
| 87 | +(defn drain! [pcm] (check! (raw-drain pcm) "drain") nil) | ||
| 88 | +(defn close! [pcm] (raw-close pcm) nil) | ||
added
src/frq/capture/v4l2.clj +264 -0 | new file mode 100644 | ||
| @@ -0,0 +1,264 @@ | ||
| 1 | +(ns frq.capture.v4l2 | |
| 2 | + "The camera, behind an ioctl. | |
| 3 | + | |
| 4 | + V4L2 is the one part of the media plane that was never going to need a | |
| 5 | + library: it is `open`, `ioctl`, `mmap` and a handful of structs, all of it | |
| 6 | + in libc and the kernel. What it needs instead is EXACTNESS. Every request | |
| 7 | + number below encodes the size of the struct it carries, so a layout that is | |
| 8 | + one byte wrong does not read a wrong field — it makes a request number the | |
| 9 | + kernel has never heard of, and the driver answers ENOTTY for an ioctl that | |
| 10 | + plainly exists. | |
| 11 | + | |
| 12 | + That is why `frq.capture.v4l2-test/check-layouts` compares every size and | |
| 13 | + offset here against what a C compiler says about the running kernel's | |
| 14 | + headers, rather than trusting that they were transcribed correctly. | |
| 15 | + | |
| 16 | + A CAPTURED FRAME IS BORROWED. `with-frame` hands the mmap'd buffer straight | |
| 17 | + to its callback and requeues it afterwards; the pointer is the driver's, it | |
| 18 | + is valid until the buffer goes back, and nothing here copies it. That is | |
| 19 | + `frq.av`'s rule arriving from the other end — capture buffer to encoder as a | |
| 20 | + pointer, the way the decoder's buffer already reaches a texture." | |
| 21 | + (:require [jolt.ffi :as ffi])) | |
| 22 | + | |
| 23 | +;; --- libc -------------------------------------------------------------------- | |
| 24 | + | |
| 25 | +(ffi/defcfn c-open "open" [:string :int :& :int] :int) | |
| 26 | +(ffi/defcfn c-close "close" [:int] :int) | |
| 27 | +;; ioctl's third argument is whatever the request says it is; for every | |
| 28 | +;; request here it is a pointer, and the declared tail costs no compile. | |
| 29 | +(ffi/defcfn c-ioctl "ioctl" [:int :uint64 :& :pointer] :int) | |
| 30 | +(ffi/defcfn c-mmap "mmap" [:pointer :uint64 :int :int :int :int64] :pointer) | |
| 31 | +(ffi/defcfn c-munmap "munmap" [:pointer :uint64] :int) | |
| 32 | + | |
| 33 | +(def ^:const o-rdwr 2) | |
| 34 | +(def ^:const prot-read 1) | |
| 35 | +(def ^:const prot-write 2) | |
| 36 | +(def ^:const map-shared 1) | |
| 37 | + | |
| 38 | +;; --- the requests ------------------------------------------------------------ | |
| 39 | +;; _IOC(dir, type, nr, size) = dir<<30 | size<<16 | 'V'<<8 | nr, with dir 1 | |
| 40 | +;; for write, 2 for read and 3 for both. The size in there is the struct's, | |
| 41 | +;; which is why the layouts below are checked rather than assumed. | |
| 42 | + | |
| 43 | +(def ^:const VIDIOC_QUERYCAP 2154321408) | |
| 44 | +(def ^:const VIDIOC_S_FMT 3234878981) | |
| 45 | +(def ^:const VIDIOC_REQBUFS 3222558216) | |
| 46 | +(def ^:const VIDIOC_QUERYBUF 3227014665) | |
| 47 | +(def ^:const VIDIOC_QBUF 3227014671) | |
| 48 | +(def ^:const VIDIOC_DQBUF 3227014673) | |
| 49 | +(def ^:const VIDIOC_STREAMON 1074026002) | |
| 50 | +(def ^:const VIDIOC_STREAMOFF 1074026003) | |
| 51 | + | |
| 52 | +(def ^:const buf-type-video-capture 1) | |
| 53 | +(def ^:const memory-mmap 1) | |
| 54 | +(def ^:const field-none 1) | |
| 55 | +(def ^:const cap-video-capture 1) | |
| 56 | +(def ^:const cap-streaming 67108864) | |
| 57 | + | |
| 58 | +(def pixel-formats | |
| 59 | + "V4L2 fourccs, as the kernel packs them." | |
| 60 | + {:yuyv 1448695129 :mjpeg 1196444237 :yuv420 842093913}) | |
| 61 | + | |
| 62 | +;; --- the structs ------------------------------------------------------------- | |
| 63 | +;; Padded to the kernel's sizes rather than described field by field: what | |
| 64 | +;; matters is the total size (it is in the request number) and the offsets of | |
| 65 | +;; the fields actually read. A union is spelled as the reserved block it | |
| 66 | +;; occupies, which is what `v4l2_format` mostly is. | |
| 67 | + | |
| 68 | +(def capability | |
| 69 | + (ffi/layout [:struct [[:driver [:array :uint8 16]] | |
| 70 | + [:card [:array :uint8 32]] | |
| 71 | + [:bus-info [:array :uint8 32]] | |
| 72 | + [:version :uint32] | |
| 73 | + [:capabilities :uint32] | |
| 74 | + [:device-caps :uint32] | |
| 75 | + [:reserved [:array :uint32 3]]]])) | |
| 76 | + | |
| 77 | +(def format-pix | |
| 78 | + ;; v4l2_format is 208 bytes: a type, four bytes of padding, then a union | |
| 79 | + ;; whose largest member decides the rest. Only the pix arm is described; | |
| 80 | + ;; the tail is the union's remaining bytes. | |
| 81 | + (ffi/layout [:struct [[:type :uint32] | |
| 82 | + [:pad :uint32] | |
| 83 | + [:width :uint32] | |
| 84 | + [:height :uint32] | |
| 85 | + [:pixelformat :uint32] | |
| 86 | + [:field :uint32] | |
| 87 | + [:bytesperline :uint32] | |
| 88 | + [:sizeimage :uint32] | |
| 89 | + [:colorspace :uint32] | |
| 90 | + [:priv :uint32] | |
| 91 | + [:flags :uint32] | |
| 92 | + [:enc :uint32] | |
| 93 | + [:quantization :uint32] | |
| 94 | + [:xfer-func :uint32] | |
| 95 | + [:rest [:array :uint8 152]]]])) | |
| 96 | + | |
| 97 | +(def requestbuffers | |
| 98 | + (ffi/layout [:struct [[:count :uint32] | |
| 99 | + [:type :uint32] | |
| 100 | + [:memory :uint32] | |
| 101 | + [:capabilities :uint32] | |
| 102 | + [:flags :uint8] | |
| 103 | + [:reserved [:array :uint8 3]]]])) | |
| 104 | + | |
| 105 | +(def buffer | |
| 106 | + ;; 88 bytes. `timestamp` is a struct timeval at 24, `m` is a union at 64 | |
| 107 | + ;; whose first member is the mmap offset, and `memory` sits at 60. | |
| 108 | + (ffi/layout [:struct [[:index :uint32] | |
| 109 | + [:type :uint32] | |
| 110 | + [:bytesused :uint32] | |
| 111 | + [:flags :uint32] | |
| 112 | + [:field :uint32] | |
| 113 | + [:pad0 :uint32] | |
| 114 | + [:tv-sec :int64] | |
| 115 | + [:tv-usec :int64] | |
| 116 | + [:timecode [:array :uint8 16]] | |
| 117 | + [:sequence :uint32] | |
| 118 | + [:memory :uint32] | |
| 119 | + [:offset :uint32] | |
| 120 | + [:pad1 :uint32] | |
| 121 | + [:length :uint32] | |
| 122 | + [:reserved2 :uint32] | |
| 123 | + [:request-fd :int32] | |
| 124 | + [:pad2 :uint32]]])) | |
| 125 | + | |
| 126 | +;; --- opening ----------------------------------------------------------------- | |
| 127 | + | |
| 128 | +(defn- ioctl! [fd req p what] | |
| 129 | + (let [rc (c-ioctl fd req p)] | |
| 130 | + (when (neg? rc) | |
| 131 | + (throw (ex-info (str "v4l2: " what " failed") {:errno (ffi/errno) :op what}))) | |
| 132 | + rc)) | |
| 133 | + | |
| 134 | +(defn open-device | |
| 135 | + "Open a camera and answer its fd." | |
| 136 | + [path] | |
| 137 | + (let [fd (c-open path o-rdwr)] | |
| 138 | + (when (neg? fd) | |
| 139 | + (throw (ex-info (str "v4l2: cannot open " path) {:errno (ffi/errno) :path path}))) | |
| 140 | + fd)) | |
| 141 | + | |
| 142 | +(defn capabilities | |
| 143 | + "What the device says it can do. `:capture?` and `:streaming?` are the two | |
| 144 | + that decide whether the rest of this namespace applies to it." | |
| 145 | + [fd] | |
| 146 | + (ffi/with-arena [a] | |
| 147 | + (let [p (ffi/alloc a (ffi/layout-size capability))] | |
| 148 | + (ioctl! fd VIDIOC_QUERYCAP p "QUERYCAP") | |
| 149 | + (let [caps (ffi/read-field p capability [:capabilities]) | |
| 150 | + dev (ffi/read-field p capability [:device-caps]) | |
| 151 | + ;; device_caps describes THIS node; capabilities describes the | |
| 152 | + ;; whole device, which on a multi-node camera is not the same | |
| 153 | + ;; thing and is the usual reason a /dev/video1 refuses to stream. | |
| 154 | + eff (if (zero? dev) caps dev)] | |
| 155 | + {:capabilities caps | |
| 156 | + :device-caps dev | |
| 157 | + :capture? (pos? (bit-and eff cap-video-capture)) | |
| 158 | + :streaming? (pos? (bit-and eff cap-streaming))})))) | |
| 159 | + | |
| 160 | +(defn set-format! | |
| 161 | + "Ask for a size and pixel format; answers what the driver actually chose. | |
| 162 | + | |
| 163 | + V4L2 negotiates rather than obeys — a driver may answer a different size or | |
| 164 | + a different format entirely, and the returned map is the truth." | |
| 165 | + [fd width height pixel-format] | |
| 166 | + (ffi/with-arena [a] | |
| 167 | + (let [p (ffi/alloc a (ffi/layout-size format-pix)) | |
| 168 | + fourcc (or (pixel-formats pixel-format) pixel-format)] | |
| 169 | + (ffi/write p format-pix {:type buf-type-video-capture :pad 0 | |
| 170 | + :width width :height height | |
| 171 | + :pixelformat fourcc :field field-none | |
| 172 | + :bytesperline 0 :sizeimage 0 :colorspace 0 | |
| 173 | + :priv 0 :flags 0 :enc 0 :quantization 0 | |
| 174 | + :xfer-func 0 :rest (vec (repeat 152 0))}) | |
| 175 | + (ioctl! fd VIDIOC_S_FMT p "S_FMT") | |
| 176 | + {:width (ffi/read-field p format-pix [:width]) | |
| 177 | + :height (ffi/read-field p format-pix [:height]) | |
| 178 | + :pixelformat (ffi/read-field p format-pix [:pixelformat]) | |
| 179 | + :bytesperline (ffi/read-field p format-pix [:bytesperline]) | |
| 180 | + :sizeimage (ffi/read-field p format-pix [:sizeimage])}))) | |
| 181 | + | |
| 182 | +;; --- buffers ----------------------------------------------------------------- | |
| 183 | + | |
| 184 | +(defn request-buffers! | |
| 185 | + "Ask the driver for `n` mmap buffers; answers how many it granted." | |
| 186 | + [fd n] | |
| 187 | + (ffi/with-arena [a] | |
| 188 | + (let [p (ffi/alloc a (ffi/layout-size requestbuffers))] | |
| 189 | + (ffi/write p requestbuffers {:count n :type buf-type-video-capture | |
| 190 | + :memory memory-mmap :capabilities 0 | |
| 191 | + :flags 0 :reserved [0 0 0]}) | |
| 192 | + (ioctl! fd VIDIOC_REQBUFS p "REQBUFS") | |
| 193 | + (ffi/read-field p requestbuffers [:count])))) | |
| 194 | + | |
| 195 | +(defn- blank-buffer [p index] | |
| 196 | + (ffi/write p buffer {:index index :type buf-type-video-capture :bytesused 0 | |
| 197 | + :flags 0 :field 0 :pad0 0 :tv-sec 0 :tv-usec 0 | |
| 198 | + :timecode (vec (repeat 16 0)) :sequence 0 | |
| 199 | + :memory memory-mmap :offset 0 :pad1 0 :length 0 | |
| 200 | + :reserved2 0 :request-fd 0 :pad2 0})) | |
| 201 | + | |
| 202 | +(defn map-buffers! | |
| 203 | + "QUERYBUF then mmap each buffer; answers a vector of {:ptr :len :index}." | |
| 204 | + [fd n] | |
| 205 | + (ffi/with-arena [a] | |
| 206 | + (let [p (ffi/alloc a (ffi/layout-size buffer))] | |
| 207 | + (mapv (fn [i] | |
| 208 | + (blank-buffer p i) | |
| 209 | + (ioctl! fd VIDIOC_QUERYBUF p "QUERYBUF") | |
| 210 | + (let [len (ffi/read-field p buffer [:length]) | |
| 211 | + off (ffi/read-field p buffer [:offset]) | |
| 212 | + ptr (c-mmap ffi/null len (bit-or prot-read prot-write) | |
| 213 | + map-shared fd off)] | |
| 214 | + (when (= ptr -1) | |
| 215 | + (throw (ex-info "v4l2: mmap failed" {:errno (ffi/errno) :index i}))) | |
| 216 | + {:index i :ptr ptr :len len})) | |
| 217 | + (range n))))) | |
| 218 | + | |
| 219 | +(defn queue! | |
| 220 | + "Hand a buffer back to the driver." | |
| 221 | + [fd index] | |
| 222 | + (ffi/with-arena [a] | |
| 223 | + (let [p (ffi/alloc a (ffi/layout-size buffer))] | |
| 224 | + (blank-buffer p index) | |
| 225 | + (ioctl! fd VIDIOC_QBUF p "QBUF"))) | |
| 226 | + nil) | |
| 227 | + | |
| 228 | +(defn stream-on! [fd] | |
| 229 | + (ffi/with-arena [a] | |
| 230 | + (let [t (ffi/alloc a 4)] | |
| 231 | + (ffi/write t :uint32 buf-type-video-capture) | |
| 232 | + (ioctl! fd VIDIOC_STREAMON t "STREAMON"))) | |
| 233 | + nil) | |
| 234 | + | |
| 235 | +(defn stream-off! [fd] | |
| 236 | + (ffi/with-arena [a] | |
| 237 | + (let [t (ffi/alloc a 4)] | |
| 238 | + (ffi/write t :uint32 buf-type-video-capture) | |
| 239 | + (ioctl! fd VIDIOC_STREAMOFF t "STREAMOFF"))) | |
| 240 | + nil) | |
| 241 | + | |
| 242 | +(defn with-frame | |
| 243 | + "Dequeue a frame, hand it to `f` as [pointer length], and requeue it. | |
| 244 | + | |
| 245 | + The pointer is the driver's mmap'd buffer, valid only until the requeue — | |
| 246 | + which is why the buffer goes back in a `finally` and why `f` is called | |
| 247 | + rather than the span being answered. Copying it here would be the one copy | |
| 248 | + frq.av exists to avoid." | |
| 249 | + [fd buffers f] | |
| 250 | + (ffi/with-arena [a] | |
| 251 | + (let [p (ffi/alloc a (ffi/layout-size buffer))] | |
| 252 | + (blank-buffer p 0) | |
| 253 | + (ioctl! fd VIDIOC_DQBUF p "DQBUF") | |
| 254 | + (let [i (ffi/read-field p buffer [:index]) | |
| 255 | + n (ffi/read-field p buffer [:bytesused]) | |
| 256 | + buf (nth buffers i)] | |
| 257 | + (try | |
| 258 | + (f (:ptr buf) n) | |
| 259 | + (finally (queue! fd i))))))) | |
| 260 | + | |
| 261 | +(defn close-device! [fd buffers] | |
| 262 | + (doseq [{:keys [ptr len]} buffers] (c-munmap ptr len)) | |
| 263 | + (c-close fd) | |
| 264 | + nil) | |
| new file mode 100644 | |||
| @@ -0,0 +1,264 @@ | |||
| 1 | +(ns frq.capture.v4l2 | ||
| 2 | + "The camera, behind an ioctl. | ||
| 3 | + | ||
| 4 | + V4L2 is the one part of the media plane that was never going to need a | ||
| 5 | + library: it is `open`, `ioctl`, `mmap` and a handful of structs, all of it | ||
| 6 | + in libc and the kernel. What it needs instead is EXACTNESS. Every request | ||
| 7 | + number below encodes the size of the struct it carries, so a layout that is | ||
| 8 | + one byte wrong does not read a wrong field — it makes a request number the | ||
| 9 | + kernel has never heard of, and the driver answers ENOTTY for an ioctl that | ||
| 10 | + plainly exists. | ||
| 11 | + | ||
| 12 | + That is why `frq.capture.v4l2-test/check-layouts` compares every size and | ||
| 13 | + offset here against what a C compiler says about the running kernel's | ||
| 14 | + headers, rather than trusting that they were transcribed correctly. | ||
| 15 | + | ||
| 16 | + A CAPTURED FRAME IS BORROWED. `with-frame` hands the mmap'd buffer straight | ||
| 17 | + to its callback and requeues it afterwards; the pointer is the driver's, it | ||
| 18 | + is valid until the buffer goes back, and nothing here copies it. That is | ||
| 19 | + `frq.av`'s rule arriving from the other end — capture buffer to encoder as a | ||
| 20 | + pointer, the way the decoder's buffer already reaches a texture." | ||
| 21 | + (:require [jolt.ffi :as ffi])) | ||
| 22 | + | ||
| 23 | +;; --- libc -------------------------------------------------------------------- | ||
| 24 | + | ||
| 25 | +(ffi/defcfn c-open "open" [:string :int :& :int] :int) | ||
| 26 | +(ffi/defcfn c-close "close" [:int] :int) | ||
| 27 | +;; ioctl's third argument is whatever the request says it is; for every | ||
| 28 | +;; request here it is a pointer, and the declared tail costs no compile. | ||
| 29 | +(ffi/defcfn c-ioctl "ioctl" [:int :uint64 :& :pointer] :int) | ||
| 30 | +(ffi/defcfn c-mmap "mmap" [:pointer :uint64 :int :int :int :int64] :pointer) | ||
| 31 | +(ffi/defcfn c-munmap "munmap" [:pointer :uint64] :int) | ||
| 32 | + | ||
| 33 | +(def ^:const o-rdwr 2) | ||
| 34 | +(def ^:const prot-read 1) | ||
| 35 | +(def ^:const prot-write 2) | ||
| 36 | +(def ^:const map-shared 1) | ||
| 37 | + | ||
| 38 | +;; --- the requests ------------------------------------------------------------ | ||
| 39 | +;; _IOC(dir, type, nr, size) = dir<<30 | size<<16 | 'V'<<8 | nr, with dir 1 | ||
| 40 | +;; for write, 2 for read and 3 for both. The size in there is the struct's, | ||
| 41 | +;; which is why the layouts below are checked rather than assumed. | ||
| 42 | + | ||
| 43 | +(def ^:const VIDIOC_QUERYCAP 2154321408) | ||
| 44 | +(def ^:const VIDIOC_S_FMT 3234878981) | ||
| 45 | +(def ^:const VIDIOC_REQBUFS 3222558216) | ||
| 46 | +(def ^:const VIDIOC_QUERYBUF 3227014665) | ||
| 47 | +(def ^:const VIDIOC_QBUF 3227014671) | ||
| 48 | +(def ^:const VIDIOC_DQBUF 3227014673) | ||
| 49 | +(def ^:const VIDIOC_STREAMON 1074026002) | ||
| 50 | +(def ^:const VIDIOC_STREAMOFF 1074026003) | ||
| 51 | + | ||
| 52 | +(def ^:const buf-type-video-capture 1) | ||
| 53 | +(def ^:const memory-mmap 1) | ||
| 54 | +(def ^:const field-none 1) | ||
| 55 | +(def ^:const cap-video-capture 1) | ||
| 56 | +(def ^:const cap-streaming 67108864) | ||
| 57 | + | ||
| 58 | +(def pixel-formats | ||
| 59 | + "V4L2 fourccs, as the kernel packs them." | ||
| 60 | + {:yuyv 1448695129 :mjpeg 1196444237 :yuv420 842093913}) | ||
| 61 | + | ||
| 62 | +;; --- the structs ------------------------------------------------------------- | ||
| 63 | +;; Padded to the kernel's sizes rather than described field by field: what | ||
| 64 | +;; matters is the total size (it is in the request number) and the offsets of | ||
| 65 | +;; the fields actually read. A union is spelled as the reserved block it | ||
| 66 | +;; occupies, which is what `v4l2_format` mostly is. | ||
| 67 | + | ||
| 68 | +(def capability | ||
| 69 | + (ffi/layout [:struct [[:driver [:array :uint8 16]] | ||
| 70 | + [:card [:array :uint8 32]] | ||
| 71 | + [:bus-info [:array :uint8 32]] | ||
| 72 | + [:version :uint32] | ||
| 73 | + [:capabilities :uint32] | ||
| 74 | + [:device-caps :uint32] | ||
| 75 | + [:reserved [:array :uint32 3]]]])) | ||
| 76 | + | ||
| 77 | +(def format-pix | ||
| 78 | + ;; v4l2_format is 208 bytes: a type, four bytes of padding, then a union | ||
| 79 | + ;; whose largest member decides the rest. Only the pix arm is described; | ||
| 80 | + ;; the tail is the union's remaining bytes. | ||
| 81 | + (ffi/layout [:struct [[:type :uint32] | ||
| 82 | + [:pad :uint32] | ||
| 83 | + [:width :uint32] | ||
| 84 | + [:height :uint32] | ||
| 85 | + [:pixelformat :uint32] | ||
| 86 | + [:field :uint32] | ||
| 87 | + [:bytesperline :uint32] | ||
| 88 | + [:sizeimage :uint32] | ||
| 89 | + [:colorspace :uint32] | ||
| 90 | + [:priv :uint32] | ||
| 91 | + [:flags :uint32] | ||
| 92 | + [:enc :uint32] | ||
| 93 | + [:quantization :uint32] | ||
| 94 | + [:xfer-func :uint32] | ||
| 95 | + [:rest [:array :uint8 152]]]])) | ||
| 96 | + | ||
| 97 | +(def requestbuffers | ||
| 98 | + (ffi/layout [:struct [[:count :uint32] | ||
| 99 | + [:type :uint32] | ||
| 100 | + [:memory :uint32] | ||
| 101 | + [:capabilities :uint32] | ||
| 102 | + [:flags :uint8] | ||
| 103 | + [:reserved [:array :uint8 3]]]])) | ||
| 104 | + | ||
| 105 | +(def buffer | ||
| 106 | + ;; 88 bytes. `timestamp` is a struct timeval at 24, `m` is a union at 64 | ||
| 107 | + ;; whose first member is the mmap offset, and `memory` sits at 60. | ||
| 108 | + (ffi/layout [:struct [[:index :uint32] | ||
| 109 | + [:type :uint32] | ||
| 110 | + [:bytesused :uint32] | ||
| 111 | + [:flags :uint32] | ||
| 112 | + [:field :uint32] | ||
| 113 | + [:pad0 :uint32] | ||
| 114 | + [:tv-sec :int64] | ||
| 115 | + [:tv-usec :int64] | ||
| 116 | + [:timecode [:array :uint8 16]] | ||
| 117 | + [:sequence :uint32] | ||
| 118 | + [:memory :uint32] | ||
| 119 | + [:offset :uint32] | ||
| 120 | + [:pad1 :uint32] | ||
| 121 | + [:length :uint32] | ||
| 122 | + [:reserved2 :uint32] | ||
| 123 | + [:request-fd :int32] | ||
| 124 | + [:pad2 :uint32]]])) | ||
| 125 | + | ||
| 126 | +;; --- opening ----------------------------------------------------------------- | ||
| 127 | + | ||
| 128 | +(defn- ioctl! [fd req p what] | ||
| 129 | + (let [rc (c-ioctl fd req p)] | ||
| 130 | + (when (neg? rc) | ||
| 131 | + (throw (ex-info (str "v4l2: " what " failed") {:errno (ffi/errno) :op what}))) | ||
| 132 | + rc)) | ||
| 133 | + | ||
| 134 | +(defn open-device | ||
| 135 | + "Open a camera and answer its fd." | ||
| 136 | + [path] | ||
| 137 | + (let [fd (c-open path o-rdwr)] | ||
| 138 | + (when (neg? fd) | ||
| 139 | + (throw (ex-info (str "v4l2: cannot open " path) {:errno (ffi/errno) :path path}))) | ||
| 140 | + fd)) | ||
| 141 | + | ||
| 142 | +(defn capabilities | ||
| 143 | + "What the device says it can do. `:capture?` and `:streaming?` are the two | ||
| 144 | + that decide whether the rest of this namespace applies to it." | ||
| 145 | + [fd] | ||
| 146 | + (ffi/with-arena [a] | ||
| 147 | + (let [p (ffi/alloc a (ffi/layout-size capability))] | ||
| 148 | + (ioctl! fd VIDIOC_QUERYCAP p "QUERYCAP") | ||
| 149 | + (let [caps (ffi/read-field p capability [:capabilities]) | ||
| 150 | + dev (ffi/read-field p capability [:device-caps]) | ||
| 151 | + ;; device_caps describes THIS node; capabilities describes the | ||
| 152 | + ;; whole device, which on a multi-node camera is not the same | ||
| 153 | + ;; thing and is the usual reason a /dev/video1 refuses to stream. | ||
| 154 | + eff (if (zero? dev) caps dev)] | ||
| 155 | + {:capabilities caps | ||
| 156 | + :device-caps dev | ||
| 157 | + :capture? (pos? (bit-and eff cap-video-capture)) | ||
| 158 | + :streaming? (pos? (bit-and eff cap-streaming))})))) | ||
| 159 | + | ||
| 160 | +(defn set-format! | ||
| 161 | + "Ask for a size and pixel format; answers what the driver actually chose. | ||
| 162 | + | ||
| 163 | + V4L2 negotiates rather than obeys — a driver may answer a different size or | ||
| 164 | + a different format entirely, and the returned map is the truth." | ||
| 165 | + [fd width height pixel-format] | ||
| 166 | + (ffi/with-arena [a] | ||
| 167 | + (let [p (ffi/alloc a (ffi/layout-size format-pix)) | ||
| 168 | + fourcc (or (pixel-formats pixel-format) pixel-format)] | ||
| 169 | + (ffi/write p format-pix {:type buf-type-video-capture :pad 0 | ||
| 170 | + :width width :height height | ||
| 171 | + :pixelformat fourcc :field field-none | ||
| 172 | + :bytesperline 0 :sizeimage 0 :colorspace 0 | ||
| 173 | + :priv 0 :flags 0 :enc 0 :quantization 0 | ||
| 174 | + :xfer-func 0 :rest (vec (repeat 152 0))}) | ||
| 175 | + (ioctl! fd VIDIOC_S_FMT p "S_FMT") | ||
| 176 | + {:width (ffi/read-field p format-pix [:width]) | ||
| 177 | + :height (ffi/read-field p format-pix [:height]) | ||
| 178 | + :pixelformat (ffi/read-field p format-pix [:pixelformat]) | ||
| 179 | + :bytesperline (ffi/read-field p format-pix [:bytesperline]) | ||
| 180 | + :sizeimage (ffi/read-field p format-pix [:sizeimage])}))) | ||
| 181 | + | ||
| 182 | +;; --- buffers ----------------------------------------------------------------- | ||
| 183 | + | ||
| 184 | +(defn request-buffers! | ||
| 185 | + "Ask the driver for `n` mmap buffers; answers how many it granted." | ||
| 186 | + [fd n] | ||
| 187 | + (ffi/with-arena [a] | ||
| 188 | + (let [p (ffi/alloc a (ffi/layout-size requestbuffers))] | ||
| 189 | + (ffi/write p requestbuffers {:count n :type buf-type-video-capture | ||
| 190 | + :memory memory-mmap :capabilities 0 | ||
| 191 | + :flags 0 :reserved [0 0 0]}) | ||
| 192 | + (ioctl! fd VIDIOC_REQBUFS p "REQBUFS") | ||
| 193 | + (ffi/read-field p requestbuffers [:count])))) | ||
| 194 | + | ||
| 195 | +(defn- blank-buffer [p index] | ||
| 196 | + (ffi/write p buffer {:index index :type buf-type-video-capture :bytesused 0 | ||
| 197 | + :flags 0 :field 0 :pad0 0 :tv-sec 0 :tv-usec 0 | ||
| 198 | + :timecode (vec (repeat 16 0)) :sequence 0 | ||
| 199 | + :memory memory-mmap :offset 0 :pad1 0 :length 0 | ||
| 200 | + :reserved2 0 :request-fd 0 :pad2 0})) | ||
| 201 | + | ||
| 202 | +(defn map-buffers! | ||
| 203 | + "QUERYBUF then mmap each buffer; answers a vector of {:ptr :len :index}." | ||
| 204 | + [fd n] | ||
| 205 | + (ffi/with-arena [a] | ||
| 206 | + (let [p (ffi/alloc a (ffi/layout-size buffer))] | ||
| 207 | + (mapv (fn [i] | ||
| 208 | + (blank-buffer p i) | ||
| 209 | + (ioctl! fd VIDIOC_QUERYBUF p "QUERYBUF") | ||
| 210 | + (let [len (ffi/read-field p buffer [:length]) | ||
| 211 | + off (ffi/read-field p buffer [:offset]) | ||
| 212 | + ptr (c-mmap ffi/null len (bit-or prot-read prot-write) | ||
| 213 | + map-shared fd off)] | ||
| 214 | + (when (= ptr -1) | ||
| 215 | + (throw (ex-info "v4l2: mmap failed" {:errno (ffi/errno) :index i}))) | ||
| 216 | + {:index i :ptr ptr :len len})) | ||
| 217 | + (range n))))) | ||
| 218 | + | ||
| 219 | +(defn queue! | ||
| 220 | + "Hand a buffer back to the driver." | ||
| 221 | + [fd index] | ||
| 222 | + (ffi/with-arena [a] | ||
| 223 | + (let [p (ffi/alloc a (ffi/layout-size buffer))] | ||
| 224 | + (blank-buffer p index) | ||
| 225 | + (ioctl! fd VIDIOC_QBUF p "QBUF"))) | ||
| 226 | + nil) | ||
| 227 | + | ||
| 228 | +(defn stream-on! [fd] | ||
| 229 | + (ffi/with-arena [a] | ||
| 230 | + (let [t (ffi/alloc a 4)] | ||
| 231 | + (ffi/write t :uint32 buf-type-video-capture) | ||
| 232 | + (ioctl! fd VIDIOC_STREAMON t "STREAMON"))) | ||
| 233 | + nil) | ||
| 234 | + | ||
| 235 | +(defn stream-off! [fd] | ||
| 236 | + (ffi/with-arena [a] | ||
| 237 | + (let [t (ffi/alloc a 4)] | ||
| 238 | + (ffi/write t :uint32 buf-type-video-capture) | ||
| 239 | + (ioctl! fd VIDIOC_STREAMOFF t "STREAMOFF"))) | ||
| 240 | + nil) | ||
| 241 | + | ||
| 242 | +(defn with-frame | ||
| 243 | + "Dequeue a frame, hand it to `f` as [pointer length], and requeue it. | ||
| 244 | + | ||
| 245 | + The pointer is the driver's mmap'd buffer, valid only until the requeue — | ||
| 246 | + which is why the buffer goes back in a `finally` and why `f` is called | ||
| 247 | + rather than the span being answered. Copying it here would be the one copy | ||
| 248 | + frq.av exists to avoid." | ||
| 249 | + [fd buffers f] | ||
| 250 | + (ffi/with-arena [a] | ||
| 251 | + (let [p (ffi/alloc a (ffi/layout-size buffer))] | ||
| 252 | + (blank-buffer p 0) | ||
| 253 | + (ioctl! fd VIDIOC_DQBUF p "DQBUF") | ||
| 254 | + (let [i (ffi/read-field p buffer [:index]) | ||
| 255 | + n (ffi/read-field p buffer [:bytesused]) | ||
| 256 | + buf (nth buffers i)] | ||
| 257 | + (try | ||
| 258 | + (f (:ptr buf) n) | ||
| 259 | + (finally (queue! fd i))))))) | ||
| 260 | + | ||
| 261 | +(defn close-device! [fd buffers] | ||
| 262 | + (doseq [{:keys [ptr len]} buffers] (c-munmap ptr len)) | ||
| 263 | + (c-close fd) | ||
| 264 | + nil) | ||
added
src/frq/codec/h264.clj +84 -0 | new file mode 100644 | ||
| @@ -0,0 +1,84 @@ | ||
| 1 | +(ns frq.codec.h264 | |
| 2 | + "H.264 encoding, through openh264. | |
| 3 | + | |
| 4 | + Not through openh264 DIRECTLY, and the reason is a calling convention. | |
| 5 | + openh264's C API is not flat: `ISVCEncoder` is `const ISVCEncoderVtbl*`, so | |
| 6 | + every method — Initialize, EncodeFrame, Uninitialize — is a function pointer | |
| 7 | + read out of a table hanging off the object. jolt.ffi cannot call one. Chez | |
| 8 | + fixes a foreign procedure's argument and result types when it COMPILES it, | |
| 9 | + which is also why the target has to be a literal C symbol name rather than | |
| 10 | + an address; `jolt/ffi.clj` says so in as many words. | |
| 11 | + | |
| 12 | + So `c/frq_h264.c` walks the vtable and exports five plain symbols, and this | |
| 13 | + namespace binds those. The shim is a hundred lines that change a calling | |
| 14 | + convention; it holds no policy and makes no decisions that belong here. | |
| 15 | + | |
| 16 | + It does flatten the output, because that part cannot sensibly live in jolt: | |
| 17 | + openh264 answers an `SFrameBSInfo` of layers, each with its own NAL count | |
| 18 | + over a shared buffer, and walking that from here would mean reading nested | |
| 19 | + C structs whose layout is openh264's business rather than ours. | |
| 20 | + | |
| 21 | + A FRAME IS BORROWED, both ways. `encode!` takes a pointer to I420 and | |
| 22 | + answers a span into the encoder's own buffer, valid until the next call on | |
| 23 | + the same encoder. That is `frq.av`'s existing contract for video — decoder | |
| 24 | + buffer to texture as a pointer, never copied on this side — and it is why | |
| 25 | + nothing here turns a picture into a jolt value." | |
| 26 | + (:require [jolt.ffi :as ffi])) | |
| 27 | + | |
| 28 | +(ffi/defcfn raw-open "frq_h264_open" [:int :int :int :int :pointer] :int) | |
| 29 | +(ffi/defcfn raw-encode "frq_h264_encode" | |
| 30 | + [:pointer :pointer :int64 :pointer :pointer :pointer] :int) | |
| 31 | +(ffi/defcfn raw-force-keyframe "frq_h264_force_keyframe" [:pointer] :int) | |
| 32 | +(ffi/defcfn raw-close "frq_h264_close" [:pointer] :void) | |
| 33 | + | |
| 34 | +(defn i420-size | |
| 35 | + "Bytes in one I420 frame: a luma plane, then two at quarter resolution." | |
| 36 | + [width height] | |
| 37 | + (+ (* width height) (* 2 (quot (* width height) 4)))) | |
| 38 | + | |
| 39 | +(defn encoder | |
| 40 | + "Open an encoder. `bitrate` is bits per second. | |
| 41 | + | |
| 42 | + openh264 validates here rather than at the first frame, so an impossible | |
| 43 | + size or bitrate raises now." | |
| 44 | + [{:keys [width height fps bitrate] :or {fps 30 bitrate 1000000}}] | |
| 45 | + (ffi/with-arena [a] | |
| 46 | + (let [out (ffi/alloc a 8) | |
| 47 | + rc (raw-open width height fps bitrate out)] | |
| 48 | + (when-not (zero? rc) | |
| 49 | + (throw (ex-info "openh264: could not open an encoder" | |
| 50 | + {:code rc :width width :height height | |
| 51 | + :fps fps :bitrate bitrate}))) | |
| 52 | + (let [h (ffi/read out :pointer)] | |
| 53 | + (when (ffi/null? h) | |
| 54 | + (throw (ex-info "openh264: encoder handle is NULL" {}))) | |
| 55 | + h)))) | |
| 56 | + | |
| 57 | +(defn encode! | |
| 58 | + "Encode one I420 frame and hand the result to `use-frame`. | |
| 59 | + | |
| 60 | + `i420` is a pointer to `(i420-size w h)` bytes. `use-frame` is called with | |
| 61 | + [pointer length keyframe?] and its value is answered; the span is the | |
| 62 | + encoder's own buffer and is valid only for the duration of that call. | |
| 63 | + | |
| 64 | + A frame openh264 chose to skip calls `use-frame` with a zero length rather | |
| 65 | + than raising — a skip is a decision, not a failure." | |
| 66 | + [enc i420 pts-us use-frame] | |
| 67 | + (ffi/with-arena [a] | |
| 68 | + (let [out (ffi/alloc a 8) | |
| 69 | + len (ffi/alloc a 4) | |
| 70 | + key (ffi/alloc a 4) | |
| 71 | + rc (raw-encode enc i420 pts-us out len key)] | |
| 72 | + (when-not (zero? rc) | |
| 73 | + (throw (ex-info "openh264: encode failed" {:code rc}))) | |
| 74 | + (use-frame (ffi/read out :pointer) | |
| 75 | + (ffi/read len :int32) | |
| 76 | + (not (zero? (ffi/read key :int32))))))) | |
| 77 | + | |
| 78 | +(defn force-keyframe! | |
| 79 | + "Make the next frame an IDR — what a newly arrived subscriber needs." | |
| 80 | + [enc] | |
| 81 | + (raw-force-keyframe enc) | |
| 82 | + nil) | |
| 83 | + | |
| 84 | +(defn close! [enc] (raw-close enc) nil) | |
| new file mode 100644 | |||
| @@ -0,0 +1,84 @@ | |||
| 1 | +(ns frq.codec.h264 | ||
| 2 | + "H.264 encoding, through openh264. | ||
| 3 | + | ||
| 4 | + Not through openh264 DIRECTLY, and the reason is a calling convention. | ||
| 5 | + openh264's C API is not flat: `ISVCEncoder` is `const ISVCEncoderVtbl*`, so | ||
| 6 | + every method — Initialize, EncodeFrame, Uninitialize — is a function pointer | ||
| 7 | + read out of a table hanging off the object. jolt.ffi cannot call one. Chez | ||
| 8 | + fixes a foreign procedure's argument and result types when it COMPILES it, | ||
| 9 | + which is also why the target has to be a literal C symbol name rather than | ||
| 10 | + an address; `jolt/ffi.clj` says so in as many words. | ||
| 11 | + | ||
| 12 | + So `c/frq_h264.c` walks the vtable and exports five plain symbols, and this | ||
| 13 | + namespace binds those. The shim is a hundred lines that change a calling | ||
| 14 | + convention; it holds no policy and makes no decisions that belong here. | ||
| 15 | + | ||
| 16 | + It does flatten the output, because that part cannot sensibly live in jolt: | ||
| 17 | + openh264 answers an `SFrameBSInfo` of layers, each with its own NAL count | ||
| 18 | + over a shared buffer, and walking that from here would mean reading nested | ||
| 19 | + C structs whose layout is openh264's business rather than ours. | ||
| 20 | + | ||
| 21 | + A FRAME IS BORROWED, both ways. `encode!` takes a pointer to I420 and | ||
| 22 | + answers a span into the encoder's own buffer, valid until the next call on | ||
| 23 | + the same encoder. That is `frq.av`'s existing contract for video — decoder | ||
| 24 | + buffer to texture as a pointer, never copied on this side — and it is why | ||
| 25 | + nothing here turns a picture into a jolt value." | ||
| 26 | + (:require [jolt.ffi :as ffi])) | ||
| 27 | + | ||
| 28 | +(ffi/defcfn raw-open "frq_h264_open" [:int :int :int :int :pointer] :int) | ||
| 29 | +(ffi/defcfn raw-encode "frq_h264_encode" | ||
| 30 | + [:pointer :pointer :int64 :pointer :pointer :pointer] :int) | ||
| 31 | +(ffi/defcfn raw-force-keyframe "frq_h264_force_keyframe" [:pointer] :int) | ||
| 32 | +(ffi/defcfn raw-close "frq_h264_close" [:pointer] :void) | ||
| 33 | + | ||
| 34 | +(defn i420-size | ||
| 35 | + "Bytes in one I420 frame: a luma plane, then two at quarter resolution." | ||
| 36 | + [width height] | ||
| 37 | + (+ (* width height) (* 2 (quot (* width height) 4)))) | ||
| 38 | + | ||
| 39 | +(defn encoder | ||
| 40 | + "Open an encoder. `bitrate` is bits per second. | ||
| 41 | + | ||
| 42 | + openh264 validates here rather than at the first frame, so an impossible | ||
| 43 | + size or bitrate raises now." | ||
| 44 | + [{:keys [width height fps bitrate] :or {fps 30 bitrate 1000000}}] | ||
| 45 | + (ffi/with-arena [a] | ||
| 46 | + (let [out (ffi/alloc a 8) | ||
| 47 | + rc (raw-open width height fps bitrate out)] | ||
| 48 | + (when-not (zero? rc) | ||
| 49 | + (throw (ex-info "openh264: could not open an encoder" | ||
| 50 | + {:code rc :width width :height height | ||
| 51 | + :fps fps :bitrate bitrate}))) | ||
| 52 | + (let [h (ffi/read out :pointer)] | ||
| 53 | + (when (ffi/null? h) | ||
| 54 | + (throw (ex-info "openh264: encoder handle is NULL" {}))) | ||
| 55 | + h)))) | ||
| 56 | + | ||
| 57 | +(defn encode! | ||
| 58 | + "Encode one I420 frame and hand the result to `use-frame`. | ||
| 59 | + | ||
| 60 | + `i420` is a pointer to `(i420-size w h)` bytes. `use-frame` is called with | ||
| 61 | + [pointer length keyframe?] and its value is answered; the span is the | ||
| 62 | + encoder's own buffer and is valid only for the duration of that call. | ||
| 63 | + | ||
| 64 | + A frame openh264 chose to skip calls `use-frame` with a zero length rather | ||
| 65 | + than raising — a skip is a decision, not a failure." | ||
| 66 | + [enc i420 pts-us use-frame] | ||
| 67 | + (ffi/with-arena [a] | ||
| 68 | + (let [out (ffi/alloc a 8) | ||
| 69 | + len (ffi/alloc a 4) | ||
| 70 | + key (ffi/alloc a 4) | ||
| 71 | + rc (raw-encode enc i420 pts-us out len key)] | ||
| 72 | + (when-not (zero? rc) | ||
| 73 | + (throw (ex-info "openh264: encode failed" {:code rc}))) | ||
| 74 | + (use-frame (ffi/read out :pointer) | ||
| 75 | + (ffi/read len :int32) | ||
| 76 | + (not (zero? (ffi/read key :int32))))))) | ||
| 77 | + | ||
| 78 | +(defn force-keyframe! | ||
| 79 | + "Make the next frame an IDR — what a newly arrived subscriber needs." | ||
| 80 | + [enc] | ||
| 81 | + (raw-force-keyframe enc) | ||
| 82 | + nil) | ||
| 83 | + | ||
| 84 | +(defn close! [enc] (raw-close enc) nil) | ||
added
src/frq/codec/opus.clj +144 -0 | new file mode 100644 | ||
| @@ -0,0 +1,144 @@ | ||
| 1 | +(ns frq.codec.opus | |
| 2 | + "Opus, bound to libopus. | |
| 3 | + | |
| 4 | + This is what the media plane's audio half becomes on this side of the port. | |
| 5 | + libmoq_ffi carries MoQ over QUIC and nothing else — moq-ffi's `audio` | |
| 6 | + feature, which would have brought Opus with it, costs a 1062-crate build — | |
| 7 | + so the codec is linked where it has always lived, in a C library with a flat | |
| 8 | + API and a twenty-year-old ABI. | |
| 9 | + | |
| 10 | + Two things about that API shape the binding. | |
| 11 | + | |
| 12 | + **PCM is int16, interleaved, and never a jolt value.** `encode!` and | |
| 13 | + `decode!` take and answer [pointer length] spans of foreign memory, the same | |
| 14 | + as `frq.moq.media`'s frame path. A 20ms stereo frame at 48kHz is 1920 | |
| 15 | + samples; turning that into a jolt vector twice per frame, fifty times a | |
| 16 | + second, is work with nothing to show for it, and `frq.av`'s rule already | |
| 17 | + says audio and video move as pointers. | |
| 18 | + | |
| 19 | + **A frame is not any length you like.** Opus encodes exactly 2.5, 5, 10, 20, | |
| 20 | + 40 or 60ms, and `frame-size` is a count of samples PER CHANNEL, not bytes | |
| 21 | + and not interleaved samples. Handing it the interleaved count is the classic | |
| 22 | + mistake: at stereo it asks for twice the duration, which is a valid frame | |
| 23 | + size, so nothing raises and the audio simply runs fast." | |
| 24 | + (:require [jolt.ffi :as ffi])) | |
| 25 | + | |
| 26 | +;; --- constants --------------------------------------------------------------- | |
| 27 | + | |
| 28 | +(def ^:const ok 0) | |
| 29 | + | |
| 30 | +(def applications | |
| 31 | + "What the encoder is being asked to optimise for. :voip is what a call | |
| 32 | + wants — it favours speech intelligibility over musical fidelity." | |
| 33 | + {:voip 2048 :audio 2049 :low-delay 2051}) | |
| 34 | + | |
| 35 | +(def ^:private errors | |
| 36 | + {0 :ok -1 :bad-arg -2 :buffer-too-small -3 :internal-error | |
| 37 | + -4 :invalid-packet -5 :unimplemented -6 :invalid-state -7 :alloc-fail}) | |
| 38 | + | |
| 39 | +(def ^:const set-bitrate-request 4002) | |
| 40 | + | |
| 41 | +;; Sample counts PER CHANNEL for one frame at 48kHz, by frame duration. | |
| 42 | +(def frame-samples-48k | |
| 43 | + {2.5 120, 5 240, 10 480, 20 960, 40 1920, 60 2880}) | |
| 44 | + | |
| 45 | +;; --- the entry points -------------------------------------------------------- | |
| 46 | + | |
| 47 | +(ffi/defcfn raw-encoder-create "opus_encoder_create" | |
| 48 | + [:int32 :int :int :pointer] :pointer) | |
| 49 | +(ffi/defcfn raw-encoder-destroy "opus_encoder_destroy" [:pointer] :void) | |
| 50 | +(ffi/defcfn raw-encode "opus_encode" | |
| 51 | + [:pointer :pointer :int :pointer :int32] :int32) | |
| 52 | +;; Variadic: the CTL request decides the tail. Declared with the one tail | |
| 53 | +;; shape this namespace uses — an int32 — rather than a bare :&, so it costs | |
| 54 | +;; no compile at the first call. | |
| 55 | +(ffi/defcfn raw-encoder-ctl "opus_encoder_ctl" [:pointer :int :& :int32] :int) | |
| 56 | + | |
| 57 | +(ffi/defcfn raw-decoder-create "opus_decoder_create" [:int32 :int :pointer] :pointer) | |
| 58 | +(ffi/defcfn raw-decoder-destroy "opus_decoder_destroy" [:pointer] :void) | |
| 59 | +(ffi/defcfn raw-decode "opus_decode" | |
| 60 | + [:pointer :pointer :int32 :pointer :int :int] :int) | |
| 61 | + | |
| 62 | +(ffi/defcfn raw-strerror "opus_strerror" [:int] :string) | |
| 63 | + | |
| 64 | +;; --- errors ------------------------------------------------------------------ | |
| 65 | + | |
| 66 | +(defn- check! | |
| 67 | + "libopus answers a negative int for every failure, in every function that | |
| 68 | + returns one. There is no errno and no out-parameter to consult except on the | |
| 69 | + constructors, so this is the whole error protocol." | |
| 70 | + [n what] | |
| 71 | + (if (neg? n) | |
| 72 | + (throw (ex-info (str "opus: " what ": " (raw-strerror n)) | |
| 73 | + {:code n :error (errors n :unknown) :op what})) | |
| 74 | + n)) | |
| 75 | + | |
| 76 | +;; --- encoding ---------------------------------------------------------------- | |
| 77 | + | |
| 78 | +(defn encoder | |
| 79 | + "An Opus encoder. `sample-rate` is one of 8000, 12000, 16000, 24000, 48000." | |
| 80 | + ([] (encoder 48000 1 :voip)) | |
| 81 | + ([sample-rate channels application] | |
| 82 | + (ffi/with-arena [a] | |
| 83 | + (let [err (ffi/alloc a 4) | |
| 84 | + enc (raw-encoder-create sample-rate channels | |
| 85 | + (or (applications application) | |
| 86 | + (throw (ex-info "unknown opus application" | |
| 87 | + {:got application | |
| 88 | + :known (keys applications)}))) | |
| 89 | + err)] | |
| 90 | + (check! (ffi/read err :int32) "encoder_create") | |
| 91 | + (when (ffi/null? enc) | |
| 92 | + (throw (ex-info "opus: encoder_create answered NULL" {}))) | |
| 93 | + enc)))) | |
| 94 | + | |
| 95 | +(defn set-bitrate! | |
| 96 | + "Bits per second across all channels." | |
| 97 | + [enc bps] | |
| 98 | + (check! (raw-encoder-ctl enc set-bitrate-request bps) "set_bitrate") | |
| 99 | + nil) | |
| 100 | + | |
| 101 | +(defn encode! | |
| 102 | + "Encode one frame; answers the number of bytes written into `out`. | |
| 103 | + | |
| 104 | + `pcm` is a pointer to interleaved int16 samples and `samples-per-channel` | |
| 105 | + counts them PER CHANNEL — see the namespace docstring on why that | |
| 106 | + distinction bites silently rather than loudly. | |
| 107 | + | |
| 108 | + A return of 2 bytes or fewer is not an error: it is DTX, the encoder saying | |
| 109 | + this frame is silence and need not be sent at all." | |
| 110 | + [enc pcm samples-per-channel out out-capacity] | |
| 111 | + (check! (raw-encode enc pcm samples-per-channel out out-capacity) "encode")) | |
| 112 | + | |
| 113 | +(defn dtx? | |
| 114 | + "Did `encode!` decide the frame was not worth sending?" | |
| 115 | + [written] | |
| 116 | + (<= written 2)) | |
| 117 | + | |
| 118 | +(defn free-encoder! [enc] (raw-encoder-destroy enc) nil) | |
| 119 | + | |
| 120 | +;; --- decoding ---------------------------------------------------------------- | |
| 121 | + | |
| 122 | +(defn decoder | |
| 123 | + ([] (decoder 48000 1)) | |
| 124 | + ([sample-rate channels] | |
| 125 | + (ffi/with-arena [a] | |
| 126 | + (let [err (ffi/alloc a 4) | |
| 127 | + dec (raw-decoder-create sample-rate channels err)] | |
| 128 | + (check! (ffi/read err :int32) "decoder_create") | |
| 129 | + (when (ffi/null? dec) | |
| 130 | + (throw (ex-info "opus: decoder_create answered NULL" {}))) | |
| 131 | + dec)))) | |
| 132 | + | |
| 133 | +(defn decode! | |
| 134 | + "Decode one packet into `pcm`; answers samples decoded PER CHANNEL. | |
| 135 | + | |
| 136 | + `capacity-per-channel` is how much room `pcm` has, again per channel. Pass a | |
| 137 | + nil packet to conceal a lost one — that is what Opus's PLC is, and it is why | |
| 138 | + `data` is allowed to be NULL where most C APIs would refuse." | |
| 139 | + [dec data len pcm capacity-per-channel] | |
| 140 | + (check! (raw-decode dec (or data ffi/null) (if data len 0) | |
| 141 | + pcm capacity-per-channel 0) | |
| 142 | + "decode")) | |
| 143 | + | |
| 144 | +(defn free-decoder! [dec] (raw-decoder-destroy dec) nil) | |
| new file mode 100644 | |||
| @@ -0,0 +1,144 @@ | |||
| 1 | +(ns frq.codec.opus | ||
| 2 | + "Opus, bound to libopus. | ||
| 3 | + | ||
| 4 | + This is what the media plane's audio half becomes on this side of the port. | ||
| 5 | + libmoq_ffi carries MoQ over QUIC and nothing else — moq-ffi's `audio` | ||
| 6 | + feature, which would have brought Opus with it, costs a 1062-crate build — | ||
| 7 | + so the codec is linked where it has always lived, in a C library with a flat | ||
| 8 | + API and a twenty-year-old ABI. | ||
| 9 | + | ||
| 10 | + Two things about that API shape the binding. | ||
| 11 | + | ||
| 12 | + **PCM is int16, interleaved, and never a jolt value.** `encode!` and | ||
| 13 | + `decode!` take and answer [pointer length] spans of foreign memory, the same | ||
| 14 | + as `frq.moq.media`'s frame path. A 20ms stereo frame at 48kHz is 1920 | ||
| 15 | + samples; turning that into a jolt vector twice per frame, fifty times a | ||
| 16 | + second, is work with nothing to show for it, and `frq.av`'s rule already | ||
| 17 | + says audio and video move as pointers. | ||
| 18 | + | ||
| 19 | + **A frame is not any length you like.** Opus encodes exactly 2.5, 5, 10, 20, | ||
| 20 | + 40 or 60ms, and `frame-size` is a count of samples PER CHANNEL, not bytes | ||
| 21 | + and not interleaved samples. Handing it the interleaved count is the classic | ||
| 22 | + mistake: at stereo it asks for twice the duration, which is a valid frame | ||
| 23 | + size, so nothing raises and the audio simply runs fast." | ||
| 24 | + (:require [jolt.ffi :as ffi])) | ||
| 25 | + | ||
| 26 | +;; --- constants --------------------------------------------------------------- | ||
| 27 | + | ||
| 28 | +(def ^:const ok 0) | ||
| 29 | + | ||
| 30 | +(def applications | ||
| 31 | + "What the encoder is being asked to optimise for. :voip is what a call | ||
| 32 | + wants — it favours speech intelligibility over musical fidelity." | ||
| 33 | + {:voip 2048 :audio 2049 :low-delay 2051}) | ||
| 34 | + | ||
| 35 | +(def ^:private errors | ||
| 36 | + {0 :ok -1 :bad-arg -2 :buffer-too-small -3 :internal-error | ||
| 37 | + -4 :invalid-packet -5 :unimplemented -6 :invalid-state -7 :alloc-fail}) | ||
| 38 | + | ||
| 39 | +(def ^:const set-bitrate-request 4002) | ||
| 40 | + | ||
| 41 | +;; Sample counts PER CHANNEL for one frame at 48kHz, by frame duration. | ||
| 42 | +(def frame-samples-48k | ||
| 43 | + {2.5 120, 5 240, 10 480, 20 960, 40 1920, 60 2880}) | ||
| 44 | + | ||
| 45 | +;; --- the entry points -------------------------------------------------------- | ||
| 46 | + | ||
| 47 | +(ffi/defcfn raw-encoder-create "opus_encoder_create" | ||
| 48 | + [:int32 :int :int :pointer] :pointer) | ||
| 49 | +(ffi/defcfn raw-encoder-destroy "opus_encoder_destroy" [:pointer] :void) | ||
| 50 | +(ffi/defcfn raw-encode "opus_encode" | ||
| 51 | + [:pointer :pointer :int :pointer :int32] :int32) | ||
| 52 | +;; Variadic: the CTL request decides the tail. Declared with the one tail | ||
| 53 | +;; shape this namespace uses — an int32 — rather than a bare :&, so it costs | ||
| 54 | +;; no compile at the first call. | ||
| 55 | +(ffi/defcfn raw-encoder-ctl "opus_encoder_ctl" [:pointer :int :& :int32] :int) | ||
| 56 | + | ||
| 57 | +(ffi/defcfn raw-decoder-create "opus_decoder_create" [:int32 :int :pointer] :pointer) | ||
| 58 | +(ffi/defcfn raw-decoder-destroy "opus_decoder_destroy" [:pointer] :void) | ||
| 59 | +(ffi/defcfn raw-decode "opus_decode" | ||
| 60 | + [:pointer :pointer :int32 :pointer :int :int] :int) | ||
| 61 | + | ||
| 62 | +(ffi/defcfn raw-strerror "opus_strerror" [:int] :string) | ||
| 63 | + | ||
| 64 | +;; --- errors ------------------------------------------------------------------ | ||
| 65 | + | ||
| 66 | +(defn- check! | ||
| 67 | + "libopus answers a negative int for every failure, in every function that | ||
| 68 | + returns one. There is no errno and no out-parameter to consult except on the | ||
| 69 | + constructors, so this is the whole error protocol." | ||
| 70 | + [n what] | ||
| 71 | + (if (neg? n) | ||
| 72 | + (throw (ex-info (str "opus: " what ": " (raw-strerror n)) | ||
| 73 | + {:code n :error (errors n :unknown) :op what})) | ||
| 74 | + n)) | ||
| 75 | + | ||
| 76 | +;; --- encoding ---------------------------------------------------------------- | ||
| 77 | + | ||
| 78 | +(defn encoder | ||
| 79 | + "An Opus encoder. `sample-rate` is one of 8000, 12000, 16000, 24000, 48000." | ||
| 80 | + ([] (encoder 48000 1 :voip)) | ||
| 81 | + ([sample-rate channels application] | ||
| 82 | + (ffi/with-arena [a] | ||
| 83 | + (let [err (ffi/alloc a 4) | ||
| 84 | + enc (raw-encoder-create sample-rate channels | ||
| 85 | + (or (applications application) | ||
| 86 | + (throw (ex-info "unknown opus application" | ||
| 87 | + {:got application | ||
| 88 | + :known (keys applications)}))) | ||
| 89 | + err)] | ||
| 90 | + (check! (ffi/read err :int32) "encoder_create") | ||
| 91 | + (when (ffi/null? enc) | ||
| 92 | + (throw (ex-info "opus: encoder_create answered NULL" {}))) | ||
| 93 | + enc)))) | ||
| 94 | + | ||
| 95 | +(defn set-bitrate! | ||
| 96 | + "Bits per second across all channels." | ||
| 97 | + [enc bps] | ||
| 98 | + (check! (raw-encoder-ctl enc set-bitrate-request bps) "set_bitrate") | ||
| 99 | + nil) | ||
| 100 | + | ||
| 101 | +(defn encode! | ||
| 102 | + "Encode one frame; answers the number of bytes written into `out`. | ||
| 103 | + | ||
| 104 | + `pcm` is a pointer to interleaved int16 samples and `samples-per-channel` | ||
| 105 | + counts them PER CHANNEL — see the namespace docstring on why that | ||
| 106 | + distinction bites silently rather than loudly. | ||
| 107 | + | ||
| 108 | + A return of 2 bytes or fewer is not an error: it is DTX, the encoder saying | ||
| 109 | + this frame is silence and need not be sent at all." | ||
| 110 | + [enc pcm samples-per-channel out out-capacity] | ||
| 111 | + (check! (raw-encode enc pcm samples-per-channel out out-capacity) "encode")) | ||
| 112 | + | ||
| 113 | +(defn dtx? | ||
| 114 | + "Did `encode!` decide the frame was not worth sending?" | ||
| 115 | + [written] | ||
| 116 | + (<= written 2)) | ||
| 117 | + | ||
| 118 | +(defn free-encoder! [enc] (raw-encoder-destroy enc) nil) | ||
| 119 | + | ||
| 120 | +;; --- decoding ---------------------------------------------------------------- | ||
| 121 | + | ||
| 122 | +(defn decoder | ||
| 123 | + ([] (decoder 48000 1)) | ||
| 124 | + ([sample-rate channels] | ||
| 125 | + (ffi/with-arena [a] | ||
| 126 | + (let [err (ffi/alloc a 4) | ||
| 127 | + dec (raw-decoder-create sample-rate channels err)] | ||
| 128 | + (check! (ffi/read err :int32) "decoder_create") | ||
| 129 | + (when (ffi/null? dec) | ||
| 130 | + (throw (ex-info "opus: decoder_create answered NULL" {}))) | ||
| 131 | + dec)))) | ||
| 132 | + | ||
| 133 | +(defn decode! | ||
| 134 | + "Decode one packet into `pcm`; answers samples decoded PER CHANNEL. | ||
| 135 | + | ||
| 136 | + `capacity-per-channel` is how much room `pcm` has, again per channel. Pass a | ||
| 137 | + nil packet to conceal a lost one — that is what Opus's PLC is, and it is why | ||
| 138 | + `data` is allowed to be NULL where most C APIs would refuse." | ||
| 139 | + [dec data len pcm capacity-per-channel] | ||
| 140 | + (check! (raw-decode dec (or data ffi/null) (if data len 0) | ||
| 141 | + pcm capacity-per-channel 0) | ||
| 142 | + "decode")) | ||
| 143 | + | ||
| 144 | +(defn free-decoder! [dec] (raw-decoder-destroy dec) nil) | ||
modified
src/frq/moq/smoke.clj +179 -1 | @@ -36,6 +36,10 @@ | ||
| 36 | 36 | [frq.moq.raw :as raw] |
| 37 | 37 | [frq.moq.client :as client] |
| 38 | 38 | [frq.moq.media :as media] |
| 39 | + [frq.codec.opus :as opus] | |
| 40 | + [frq.codec.h264 :as h264] | |
| 41 | + [frq.capture.v4l2 :as v4l2] | |
| 42 | + [frq.capture.alsa :as alsa] | |
| 39 | 43 | [jolt.ffi :as ffi])) |
| 40 | 44 | |
| 41 | 45 | (defn- check-contract [] |
| @@ -194,13 +198,187 @@ | ||
| 194 | 198 | (throw (ex-info "timestamp did not survive" {:frame frame}))) |
| 195 | 199 | true)))) |
| 196 | 200 | |
| 201 | +(defn- triangle-pcm | |
| 202 | + "20ms of a triangle wave at 48kHz mono, as int16 in FOREIGN memory. | |
| 203 | + | |
| 204 | + A triangle rather than a sine because it needs no transcendental and no | |
| 205 | + Math namespace, and rather than silence because silence is the one input | |
| 206 | + Opus is entitled to throw away: DTX would answer two bytes and the round | |
| 207 | + trip would prove nothing. | |
| 208 | + | |
| 209 | + Answers [pointer samples-per-channel]." | |
| 210 | + [a] | |
| 211 | + (let [n 960 ; 48000 * 0.020 | |
| 212 | + p (ffi/alloc a (* 2 n))] | |
| 213 | + (dotimes [i n] | |
| 214 | + ;; A 100-sample period ramp, +/- 8000 — loud enough that the decoder | |
| 215 | + ;; cannot answer silence and have it look like success. | |
| 216 | + (let [phase (mod i 100) | |
| 217 | + v (if (< phase 50) (- (* phase 320) 8000) (- 8000 (* (- phase 50) 320)))] | |
| 218 | + (ffi/write (+ p (* 2 i)) :int16 v))) | |
| 219 | + [p n])) | |
| 220 | + | |
| 221 | +(defn- check-opus | |
| 222 | + "Encode a frame with libopus and decode it back. | |
| 223 | + | |
| 224 | + This is the first piece of the port that is a C library rather than | |
| 225 | + somebody's binding to one, and the assertions are chosen to fail if it is | |
| 226 | + only pretending to work. The encoded frame must be more than DTX's two | |
| 227 | + bytes; the decode must answer exactly the frame size it was given; and the | |
| 228 | + decoded audio must carry real amplitude, because a decoder that returned | |
| 229 | + the right COUNT of zeroes would otherwise pass." | |
| 230 | + [] | |
| 231 | + (ffi/with-arena [a] | |
| 232 | + (let [enc (opus/encoder 48000 1 :voip) | |
| 233 | + dec (opus/decoder 48000 1)] | |
| 234 | + (try | |
| 235 | + (opus/set-bitrate! enc 24000) | |
| 236 | + (let [[pcm n] (triangle-pcm a) | |
| 237 | + cap 4000 | |
| 238 | + out (ffi/alloc a cap) | |
| 239 | + written (opus/encode! enc pcm n out cap)] | |
| 240 | + (println " encoded" n "samples ->" written "bytes") | |
| 241 | + (when (opus/dtx? written) | |
| 242 | + (throw (ex-info "encoder answered DTX for a loud frame" | |
| 243 | + {:written written}))) | |
| 244 | + (let [back (ffi/alloc a (* 2 n)) | |
| 245 | + decoded (opus/decode! dec out written back n) | |
| 246 | + peak (reduce (fn [m i] | |
| 247 | + (max m (abs (ffi/read (+ back (* 2 i)) :int16)))) | |
| 248 | + 0 (range decoded))] | |
| 249 | + (println " decoded" decoded "samples, peak" peak) | |
| 250 | + (when-not (= n decoded) | |
| 251 | + (throw (ex-info "decoder answered a different frame size" | |
| 252 | + {:sent n :got decoded}))) | |
| 253 | + (when (< peak 1000) | |
| 254 | + (throw (ex-info "decoded audio is silent — a lossy codec is not this lossy" | |
| 255 | + {:peak peak}))) | |
| 256 | + true)) | |
| 257 | + (finally | |
| 258 | + (opus/free-encoder! enc) | |
| 259 | + (opus/free-decoder! dec)))))) | |
| 260 | + | |
| 261 | +(defn- check-h264 | |
| 262 | + "Encode an I420 frame to H.264 through the openh264 shim. | |
| 263 | + | |
| 264 | + The assertion is the Annex B start code. A frame that came back with a | |
| 265 | + plausible length and the wrong bytes would be a vtable walked incorrectly — | |
| 266 | + the shim reading the wrong slot, or flattening the layers wrong — and a | |
| 267 | + length check alone would not catch it. 00 00 00 01 at the front, and an | |
| 268 | + IDR for the first frame, is openh264 having actually encoded something. | |
| 269 | + | |
| 270 | + The picture is flat gray, which compresses to almost nothing; what is under | |
| 271 | + test is the calling convention, not the encoder." | |
| 272 | + [] | |
| 273 | + (ffi/with-arena [a] | |
| 274 | + (let [w 64 h 64 | |
| 275 | + n (h264/i420-size w h) | |
| 276 | + px (ffi/alloc a n) | |
| 277 | + enc (h264/encoder {:width w :height h :fps 30 :bitrate 200000})] | |
| 278 | + (dotimes [i n] (ffi/write (+ px i) :uint8 0x80)) | |
| 279 | + (try | |
| 280 | + (let [got (h264/encode! enc px 0 | |
| 281 | + (fn [p len key?] | |
| 282 | + ;; The span is the encoder's buffer and dies | |
| 283 | + ;; at the next encode: read what is needed | |
| 284 | + ;; here and let it go. | |
| 285 | + {:len len | |
| 286 | + :keyframe key? | |
| 287 | + :first-4 (mapv #(ffi/read (+ p %) :uint8) | |
| 288 | + (range (min 4 len)))}))] | |
| 289 | + (println " encoded" n "bytes of I420 ->" (:len got) | |
| 290 | + "bytes, keyframe" (:keyframe got) | |
| 291 | + "starts" (pr-str (:first-4 got))) | |
| 292 | + (when (zero? (:len got)) | |
| 293 | + (throw (ex-info "encoder skipped the first frame" {}))) | |
| 294 | + (when-not (= [0 0 0 1] (:first-4 got)) | |
| 295 | + (throw (ex-info "not Annex B — no start code" {:first-4 (:first-4 got)}))) | |
| 296 | + (when-not (:keyframe got) | |
| 297 | + (throw (ex-info "first frame is not an IDR" {}))) | |
| 298 | + true) | |
| 299 | + (finally (h264/close! enc)))))) | |
| 300 | + | |
| 301 | +(defn- check-v4l2-layouts | |
| 302 | + "Check every V4L2 struct against what a C compiler says. | |
| 303 | + | |
| 304 | + There is no camera in this container, so the capture path itself cannot be | |
| 305 | + exercised here. What CAN be checked is the part most likely to be wrong and | |
| 306 | + least likely to announce it: every VIDIOC_ request number encodes the size | |
| 307 | + of the struct it carries, so a layout one byte off does not misread a field | |
| 308 | + — it produces a request the kernel has never heard of, and the driver | |
| 309 | + answers ENOTTY for an ioctl that plainly exists. | |
| 310 | + | |
| 311 | + The numbers on the right came from a C program compiled against this | |
| 312 | + kernel's own headers (scratch/v4l2probe.c). They are the ground truth this | |
| 313 | + namespace is transcribed from, so checking against them catches a typo | |
| 314 | + rather than a misunderstanding — the misunderstanding needs a device." | |
| 315 | + [] | |
| 316 | + (let [checks [["v4l2_capability size" (ffi/layout-size v4l2/capability) 104] | |
| 317 | + [" capabilities@" (ffi/field-offset v4l2/capability [:capabilities]) 84] | |
| 318 | + [" device-caps@" (ffi/field-offset v4l2/capability [:device-caps]) 88] | |
| 319 | + ["v4l2_format size" (ffi/layout-size v4l2/format-pix) 208] | |
| 320 | + [" width@" (ffi/field-offset v4l2/format-pix [:width]) 8] | |
| 321 | + [" height@" (ffi/field-offset v4l2/format-pix [:height]) 12] | |
| 322 | + [" pixelformat@" (ffi/field-offset v4l2/format-pix [:pixelformat]) 16] | |
| 323 | + [" sizeimage@" (ffi/field-offset v4l2/format-pix [:sizeimage]) 28] | |
| 324 | + ["v4l2_requestbuffers size" (ffi/layout-size v4l2/requestbuffers) 20] | |
| 325 | + ["v4l2_buffer size" (ffi/layout-size v4l2/buffer) 88] | |
| 326 | + [" bytesused@" (ffi/field-offset v4l2/buffer [:bytesused]) 8] | |
| 327 | + [" timestamp@" (ffi/field-offset v4l2/buffer [:tv-sec]) 24] | |
| 328 | + [" memory@" (ffi/field-offset v4l2/buffer [:memory]) 60] | |
| 329 | + [" m.offset@" (ffi/field-offset v4l2/buffer [:offset]) 64] | |
| 330 | + [" length@" (ffi/field-offset v4l2/buffer [:length]) 72]] | |
| 331 | + bad (remove (fn [[_ got want]] (= got want)) checks)] | |
| 332 | + (doseq [[what got want] checks] | |
| 333 | + (println (str " " what " " got (when-not (= got want) (str " WANT " want))))) | |
| 334 | + (when (seq bad) | |
| 335 | + (throw (ex-info "V4L2 layouts do not match the kernel headers" | |
| 336 | + {:mismatched (mapv (fn [[w g want]] {:what w :got g :want want}) bad)}))) | |
| 337 | + (println " (no camera here — the capture path itself is unexercised)") | |
| 338 | + true)) | |
| 339 | + | |
| 340 | +(defn- check-alsa | |
| 341 | + "Open a PCM and push frames through it. | |
| 342 | + | |
| 343 | + The device is ALSA's `null` PCM, which swallows everything and is always | |
| 344 | + present — no hardware, no permissions, and nothing that depends on what | |
| 345 | + this container happens to have plugged in. What that proves is the binding: | |
| 346 | + the library loads, the handle out-parameter comes back, set_params accepts | |
| 347 | + the format, and writei answers in FRAMES. What it cannot prove is that a | |
| 348 | + real card behaves, which needs a real card. | |
| 349 | + | |
| 350 | + The frame count is the assertion. 960 frames of mono S16 is 1920 bytes, and | |
| 351 | + a writei that answered 1920 would be this code having confused the two — | |
| 352 | + the mistake the namespace docstring warns about, and the one that looks | |
| 353 | + like a slow device rather than a bug." | |
| 354 | + [] | |
| 355 | + (ffi/with-arena [a] | |
| 356 | + (let [frames 960 | |
| 357 | + buf (ffi/alloc a (* 2 frames)) | |
| 358 | + pcm (alsa/open-pcm "null" :playback {:rate 48000 :channels 1})] | |
| 359 | + (try | |
| 360 | + (dotimes [i frames] (ffi/write (+ buf (* 2 i)) :int16 0)) | |
| 361 | + (let [{:keys [frames written recovered]} (alsa/write! pcm buf frames) | |
| 362 | + n (or frames written)] | |
| 363 | + (println (str " null pcm: wrote " n " frames" | |
| 364 | + (when recovered " (recovered from an overrun)"))) | |
| 365 | + (when-not (= 960 n) | |
| 366 | + (throw (ex-info "writei answered something other than the frame count" | |
| 367 | + {:asked 960 :got n}))) | |
| 368 | + true) | |
| 369 | + (finally (alsa/close! pcm)))))) | |
| 370 | + | |
| 197 | 371 | (defn -main [& _] |
| 198 | 372 | (println "libmoq_ffi smoke test") |
| 199 | 373 | (let [steps [["contract" check-contract] |
| 200 | 374 | ["handle" check-handle] |
| 201 | 375 | ["string" check-string] |
| 202 | 376 | ["connect" check-connect] |
| 203 | - ["media" check-media]]] | |
| 377 | + ["media" check-media] | |
| 378 | + ["opus" check-opus] | |
| 379 | + ["h264" check-h264] | |
| 380 | + ["v4l2" check-v4l2-layouts] | |
| 381 | + ["alsa" check-alsa]]] | |
| 204 | 382 | (doseq [[name f] steps] |
| 205 | 383 | (println (str name ":")) |
| 206 | 384 | (f)) |
| @@ -36,6 +36,10 @@ | |||
| 36 | [frq.moq.raw :as raw] | 36 | [frq.moq.raw :as raw] |
| 37 | [frq.moq.client :as client] | 37 | [frq.moq.client :as client] |
| 38 | [frq.moq.media :as media] | 38 | [frq.moq.media :as media] |
| 39 | + [frq.codec.opus :as opus] | ||
| 40 | + [frq.codec.h264 :as h264] | ||
| 41 | + [frq.capture.v4l2 :as v4l2] | ||
| 42 | + [frq.capture.alsa :as alsa] | ||
| 39 | [jolt.ffi :as ffi])) | 43 | [jolt.ffi :as ffi])) |
| 40 | 44 | ||
| 41 | (defn- check-contract [] | 45 | (defn- check-contract [] |
| @@ -194,13 +198,187 @@ | |||
| 194 | (throw (ex-info "timestamp did not survive" {:frame frame}))) | 198 | (throw (ex-info "timestamp did not survive" {:frame frame}))) |
| 195 | true)))) | 199 | true)))) |
| 196 | 200 | ||
| 201 | +(defn- triangle-pcm | ||
| 202 | + "20ms of a triangle wave at 48kHz mono, as int16 in FOREIGN memory. | ||
| 203 | + | ||
| 204 | + A triangle rather than a sine because it needs no transcendental and no | ||
| 205 | + Math namespace, and rather than silence because silence is the one input | ||
| 206 | + Opus is entitled to throw away: DTX would answer two bytes and the round | ||
| 207 | + trip would prove nothing. | ||
| 208 | + | ||
| 209 | + Answers [pointer samples-per-channel]." | ||
| 210 | + [a] | ||
| 211 | + (let [n 960 ; 48000 * 0.020 | ||
| 212 | + p (ffi/alloc a (* 2 n))] | ||
| 213 | + (dotimes [i n] | ||
| 214 | + ;; A 100-sample period ramp, +/- 8000 — loud enough that the decoder | ||
| 215 | + ;; cannot answer silence and have it look like success. | ||
| 216 | + (let [phase (mod i 100) | ||
| 217 | + v (if (< phase 50) (- (* phase 320) 8000) (- 8000 (* (- phase 50) 320)))] | ||
| 218 | + (ffi/write (+ p (* 2 i)) :int16 v))) | ||
| 219 | + [p n])) | ||
| 220 | + | ||
| 221 | +(defn- check-opus | ||
| 222 | + "Encode a frame with libopus and decode it back. | ||
| 223 | + | ||
| 224 | + This is the first piece of the port that is a C library rather than | ||
| 225 | + somebody's binding to one, and the assertions are chosen to fail if it is | ||
| 226 | + only pretending to work. The encoded frame must be more than DTX's two | ||
| 227 | + bytes; the decode must answer exactly the frame size it was given; and the | ||
| 228 | + decoded audio must carry real amplitude, because a decoder that returned | ||
| 229 | + the right COUNT of zeroes would otherwise pass." | ||
| 230 | + [] | ||
| 231 | + (ffi/with-arena [a] | ||
| 232 | + (let [enc (opus/encoder 48000 1 :voip) | ||
| 233 | + dec (opus/decoder 48000 1)] | ||
| 234 | + (try | ||
| 235 | + (opus/set-bitrate! enc 24000) | ||
| 236 | + (let [[pcm n] (triangle-pcm a) | ||
| 237 | + cap 4000 | ||
| 238 | + out (ffi/alloc a cap) | ||
| 239 | + written (opus/encode! enc pcm n out cap)] | ||
| 240 | + (println " encoded" n "samples ->" written "bytes") | ||
| 241 | + (when (opus/dtx? written) | ||
| 242 | + (throw (ex-info "encoder answered DTX for a loud frame" | ||
| 243 | + {:written written}))) | ||
| 244 | + (let [back (ffi/alloc a (* 2 n)) | ||
| 245 | + decoded (opus/decode! dec out written back n) | ||
| 246 | + peak (reduce (fn [m i] | ||
| 247 | + (max m (abs (ffi/read (+ back (* 2 i)) :int16)))) | ||
| 248 | + 0 (range decoded))] | ||
| 249 | + (println " decoded" decoded "samples, peak" peak) | ||
| 250 | + (when-not (= n decoded) | ||
| 251 | + (throw (ex-info "decoder answered a different frame size" | ||
| 252 | + {:sent n :got decoded}))) | ||
| 253 | + (when (< peak 1000) | ||
| 254 | + (throw (ex-info "decoded audio is silent — a lossy codec is not this lossy" | ||
| 255 | + {:peak peak}))) | ||
| 256 | + true)) | ||
| 257 | + (finally | ||
| 258 | + (opus/free-encoder! enc) | ||
| 259 | + (opus/free-decoder! dec)))))) | ||
| 260 | + | ||
| 261 | +(defn- check-h264 | ||
| 262 | + "Encode an I420 frame to H.264 through the openh264 shim. | ||
| 263 | + | ||
| 264 | + The assertion is the Annex B start code. A frame that came back with a | ||
| 265 | + plausible length and the wrong bytes would be a vtable walked incorrectly — | ||
| 266 | + the shim reading the wrong slot, or flattening the layers wrong — and a | ||
| 267 | + length check alone would not catch it. 00 00 00 01 at the front, and an | ||
| 268 | + IDR for the first frame, is openh264 having actually encoded something. | ||
| 269 | + | ||
| 270 | + The picture is flat gray, which compresses to almost nothing; what is under | ||
| 271 | + test is the calling convention, not the encoder." | ||
| 272 | + [] | ||
| 273 | + (ffi/with-arena [a] | ||
| 274 | + (let [w 64 h 64 | ||
| 275 | + n (h264/i420-size w h) | ||
| 276 | + px (ffi/alloc a n) | ||
| 277 | + enc (h264/encoder {:width w :height h :fps 30 :bitrate 200000})] | ||
| 278 | + (dotimes [i n] (ffi/write (+ px i) :uint8 0x80)) | ||
| 279 | + (try | ||
| 280 | + (let [got (h264/encode! enc px 0 | ||
| 281 | + (fn [p len key?] | ||
| 282 | + ;; The span is the encoder's buffer and dies | ||
| 283 | + ;; at the next encode: read what is needed | ||
| 284 | + ;; here and let it go. | ||
| 285 | + {:len len | ||
| 286 | + :keyframe key? | ||
| 287 | + :first-4 (mapv #(ffi/read (+ p %) :uint8) | ||
| 288 | + (range (min 4 len)))}))] | ||
| 289 | + (println " encoded" n "bytes of I420 ->" (:len got) | ||
| 290 | + "bytes, keyframe" (:keyframe got) | ||
| 291 | + "starts" (pr-str (:first-4 got))) | ||
| 292 | + (when (zero? (:len got)) | ||
| 293 | + (throw (ex-info "encoder skipped the first frame" {}))) | ||
| 294 | + (when-not (= [0 0 0 1] (:first-4 got)) | ||
| 295 | + (throw (ex-info "not Annex B — no start code" {:first-4 (:first-4 got)}))) | ||
| 296 | + (when-not (:keyframe got) | ||
| 297 | + (throw (ex-info "first frame is not an IDR" {}))) | ||
| 298 | + true) | ||
| 299 | + (finally (h264/close! enc)))))) | ||
| 300 | + | ||
| 301 | +(defn- check-v4l2-layouts | ||
| 302 | + "Check every V4L2 struct against what a C compiler says. | ||
| 303 | + | ||
| 304 | + There is no camera in this container, so the capture path itself cannot be | ||
| 305 | + exercised here. What CAN be checked is the part most likely to be wrong and | ||
| 306 | + least likely to announce it: every VIDIOC_ request number encodes the size | ||
| 307 | + of the struct it carries, so a layout one byte off does not misread a field | ||
| 308 | + — it produces a request the kernel has never heard of, and the driver | ||
| 309 | + answers ENOTTY for an ioctl that plainly exists. | ||
| 310 | + | ||
| 311 | + The numbers on the right came from a C program compiled against this | ||
| 312 | + kernel's own headers (scratch/v4l2probe.c). They are the ground truth this | ||
| 313 | + namespace is transcribed from, so checking against them catches a typo | ||
| 314 | + rather than a misunderstanding — the misunderstanding needs a device." | ||
| 315 | + [] | ||
| 316 | + (let [checks [["v4l2_capability size" (ffi/layout-size v4l2/capability) 104] | ||
| 317 | + [" capabilities@" (ffi/field-offset v4l2/capability [:capabilities]) 84] | ||
| 318 | + [" device-caps@" (ffi/field-offset v4l2/capability [:device-caps]) 88] | ||
| 319 | + ["v4l2_format size" (ffi/layout-size v4l2/format-pix) 208] | ||
| 320 | + [" width@" (ffi/field-offset v4l2/format-pix [:width]) 8] | ||
| 321 | + [" height@" (ffi/field-offset v4l2/format-pix [:height]) 12] | ||
| 322 | + [" pixelformat@" (ffi/field-offset v4l2/format-pix [:pixelformat]) 16] | ||
| 323 | + [" sizeimage@" (ffi/field-offset v4l2/format-pix [:sizeimage]) 28] | ||
| 324 | + ["v4l2_requestbuffers size" (ffi/layout-size v4l2/requestbuffers) 20] | ||
| 325 | + ["v4l2_buffer size" (ffi/layout-size v4l2/buffer) 88] | ||
| 326 | + [" bytesused@" (ffi/field-offset v4l2/buffer [:bytesused]) 8] | ||
| 327 | + [" timestamp@" (ffi/field-offset v4l2/buffer [:tv-sec]) 24] | ||
| 328 | + [" memory@" (ffi/field-offset v4l2/buffer [:memory]) 60] | ||
| 329 | + [" m.offset@" (ffi/field-offset v4l2/buffer [:offset]) 64] | ||
| 330 | + [" length@" (ffi/field-offset v4l2/buffer [:length]) 72]] | ||
| 331 | + bad (remove (fn [[_ got want]] (= got want)) checks)] | ||
| 332 | + (doseq [[what got want] checks] | ||
| 333 | + (println (str " " what " " got (when-not (= got want) (str " WANT " want))))) | ||
| 334 | + (when (seq bad) | ||
| 335 | + (throw (ex-info "V4L2 layouts do not match the kernel headers" | ||
| 336 | + {:mismatched (mapv (fn [[w g want]] {:what w :got g :want want}) bad)}))) | ||
| 337 | + (println " (no camera here — the capture path itself is unexercised)") | ||
| 338 | + true)) | ||
| 339 | + | ||
| 340 | +(defn- check-alsa | ||
| 341 | + "Open a PCM and push frames through it. | ||
| 342 | + | ||
| 343 | + The device is ALSA's `null` PCM, which swallows everything and is always | ||
| 344 | + present — no hardware, no permissions, and nothing that depends on what | ||
| 345 | + this container happens to have plugged in. What that proves is the binding: | ||
| 346 | + the library loads, the handle out-parameter comes back, set_params accepts | ||
| 347 | + the format, and writei answers in FRAMES. What it cannot prove is that a | ||
| 348 | + real card behaves, which needs a real card. | ||
| 349 | + | ||
| 350 | + The frame count is the assertion. 960 frames of mono S16 is 1920 bytes, and | ||
| 351 | + a writei that answered 1920 would be this code having confused the two — | ||
| 352 | + the mistake the namespace docstring warns about, and the one that looks | ||
| 353 | + like a slow device rather than a bug." | ||
| 354 | + [] | ||
| 355 | + (ffi/with-arena [a] | ||
| 356 | + (let [frames 960 | ||
| 357 | + buf (ffi/alloc a (* 2 frames)) | ||
| 358 | + pcm (alsa/open-pcm "null" :playback {:rate 48000 :channels 1})] | ||
| 359 | + (try | ||
| 360 | + (dotimes [i frames] (ffi/write (+ buf (* 2 i)) :int16 0)) | ||
| 361 | + (let [{:keys [frames written recovered]} (alsa/write! pcm buf frames) | ||
| 362 | + n (or frames written)] | ||
| 363 | + (println (str " null pcm: wrote " n " frames" | ||
| 364 | + (when recovered " (recovered from an overrun)"))) | ||
| 365 | + (when-not (= 960 n) | ||
| 366 | + (throw (ex-info "writei answered something other than the frame count" | ||
| 367 | + {:asked 960 :got n}))) | ||
| 368 | + true) | ||
| 369 | + (finally (alsa/close! pcm)))))) | ||
| 370 | + | ||
| 197 | (defn -main [& _] | 371 | (defn -main [& _] |
| 198 | (println "libmoq_ffi smoke test") | 372 | (println "libmoq_ffi smoke test") |
| 199 | (let [steps [["contract" check-contract] | 373 | (let [steps [["contract" check-contract] |
| 200 | ["handle" check-handle] | 374 | ["handle" check-handle] |
| 201 | ["string" check-string] | 375 | ["string" check-string] |
| 202 | ["connect" check-connect] | 376 | ["connect" check-connect] |
| 203 | - ["media" check-media]]] | 377 | + ["media" check-media] |
| 378 | + ["opus" check-opus] | ||
| 379 | + ["h264" check-h264] | ||
| 380 | + ["v4l2" check-v4l2-layouts] | ||
| 381 | + ["alsa" check-alsa]]] | ||
| 204 | (doseq [[name f] steps] | 382 | (doseq [[name f] steps] |
| 205 | (println (str name ":")) | 383 | (println (str name ":")) |
| 206 | (f)) | 384 | (f)) |