Merge pull request 'organ: el speaks — the peripheral becomes a capability of the language' (#159) from feat/el-speaks into dev
El SDK CI - dev / build-and-test (push) Failing after 10m40s
El SDK CI - dev / build-and-test (push) Failing after 10m40s
This commit was merged in pull request #159.
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
/* el_audio_darwin.m — the SPEAKER realizer. El's native audio output on Darwin.
|
||||
*
|
||||
* WHY THIS FILE EXISTS.
|
||||
*
|
||||
* Neuron could already turn meaning into samples — the render path in
|
||||
* elp/src/speech.el superposes formant resonances over a glottal source and
|
||||
* produces PCM. What it could not do was make a sound. Every path from those
|
||||
* samples to the air ran outside the language: a 939-line Swift program
|
||||
* (peripheral/src/periph.swift) that shelled out to /usr/bin/afplay. So the
|
||||
* voice was not a capability of El or of Neuron. It was a separate binary
|
||||
* standing next to them, and "speak" meant "ask that binary to speak."
|
||||
*
|
||||
* A speaker is not a language feature the way a string is, but it is exactly
|
||||
* the kind of thing a runtime owns: a device. El already owns the filesystem,
|
||||
* the network, the clock, and a graph. It should own the one output device that
|
||||
* makes it audible. After this file, `speak` is an El operation.
|
||||
*
|
||||
* WHY IT IS A REALIZER AND NOT PURE EL.
|
||||
*
|
||||
* This is the boundary the whole design turns on. Everything ABOVE the sample
|
||||
* buffer is arithmetic and belongs in El: formant geometry, superposition,
|
||||
* envelopes, WAV framing, the voice signature. Everything in this file is the
|
||||
* part that cannot be arithmetic — handing a buffer to CoreAudio and waiting
|
||||
* for the hardware to drain it. There is no way to express "the DAC has now
|
||||
* played these samples" in El, and there should not be. So the split is: El
|
||||
* computes the sound, the realizer emits it, and the realizer is as thin as it
|
||||
* can possibly be — it makes no decisions about content, it has no opinion
|
||||
* about audio, and it cannot synthesize anything.
|
||||
*
|
||||
* The precedent is eg_cosine_batch_strategy_metal_hand.m: a platform-bound
|
||||
* capability compiled as its OWN translation unit, declared in el_runtime.h,
|
||||
* and linked in where the platform supports it. Deliberately NOT a patch to
|
||||
* el_runtime.c — adding a device to El must not mean editing the core runtime,
|
||||
* for the same reason adding a modality must not (see el_runtime.c's realizer
|
||||
* registry: a realizer is resolved by name, so new organs never touch the
|
||||
* middle of the language). el_audio_null.c is the same two entry points for
|
||||
* every platform that is not Darwin, so El code that speaks still links
|
||||
* everywhere and simply reports that it has no speaker.
|
||||
*
|
||||
* WHY AudioQueue AND NOT afplay.
|
||||
*
|
||||
* afplay is a process. Using it means the sound Neuron makes is a file it wrote
|
||||
* and asked something else to open — which forces every utterance through the
|
||||
* disk, cannot start until the whole utterance exists, and puts a fork/exec
|
||||
* between the intent to speak and the sound. AudioQueue takes the samples
|
||||
* directly out of memory. Nothing is written, nothing is spawned, and a caller
|
||||
* that wants to stream can push buffers as it renders them.
|
||||
*
|
||||
* AudioToolbox ships with macOS, so this stays own-core: no cloud, no library
|
||||
* to install, no model. The output is the local speaker and nothing leaves the
|
||||
* machine — there is no network path in this file at all, by construction.
|
||||
*/
|
||||
|
||||
#import <AudioToolbox/AudioToolbox.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <stdio.h>
|
||||
#include "el_runtime.h"
|
||||
|
||||
/* Three buffers is the standard AudioQueue depth: one being played by the
|
||||
* hardware, one queued behind it, one being refilled. Fewer risks a gap on a
|
||||
* busy machine; more only adds latency before the first sound. */
|
||||
#define EL_AQ_NBUF 3
|
||||
#define EL_AQ_FRAMES 8192
|
||||
|
||||
typedef struct {
|
||||
const int16_t* pcm;
|
||||
int64_t frames;
|
||||
int64_t pos;
|
||||
volatile int inflight; /* buffers CoreAudio still owns */
|
||||
volatile int drained; /* set once the last buffer has been played */
|
||||
} ElAqState;
|
||||
|
||||
/* Called on an AudioQueue-internal thread each time a buffer finishes playing.
|
||||
* Refills and re-enqueues while samples remain; when the source is exhausted it
|
||||
* lets the buffer die and counts it out. `drained` flips only when the queue is
|
||||
* holding nothing, which is what makes the play call synchronous without
|
||||
* clipping the tail — the same reason periph.swift used .dataPlayedBack rather
|
||||
* than treating "consumed" as "heard". */
|
||||
static void el_aq_callback(void* userData, AudioQueueRef q, AudioQueueBufferRef buf) {
|
||||
ElAqState* st = (ElAqState*)userData;
|
||||
int64_t remain = st->frames - st->pos;
|
||||
if (remain <= 0) {
|
||||
if (--st->inflight <= 0) st->drained = 1;
|
||||
return;
|
||||
}
|
||||
int64_t n = remain < EL_AQ_FRAMES ? remain : EL_AQ_FRAMES;
|
||||
memcpy(buf->mAudioData, st->pcm + st->pos, (size_t)n * sizeof(int16_t));
|
||||
buf->mAudioDataByteSize = (UInt32)(n * (int64_t)sizeof(int16_t));
|
||||
st->pos += n;
|
||||
if (AudioQueueEnqueueBuffer(q, buf, 0, NULL) != noErr) {
|
||||
if (--st->inflight <= 0) st->drained = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Play a 16-bit mono PCM buffer out the default output device, blocking until
|
||||
* the hardware has actually finished. Returns 1 on success, 0 on any failure —
|
||||
* never throws, never hangs indefinitely. */
|
||||
static int el_audio_play_raw(const int16_t* pcm, int64_t frames, int32_t sample_rate) {
|
||||
if (!pcm || frames <= 0 || sample_rate <= 0) return 0;
|
||||
|
||||
AudioStreamBasicDescription fmt;
|
||||
memset(&fmt, 0, sizeof(fmt));
|
||||
fmt.mSampleRate = (Float64)sample_rate;
|
||||
fmt.mFormatID = kAudioFormatLinearPCM;
|
||||
fmt.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
|
||||
fmt.mFramesPerPacket = 1;
|
||||
fmt.mChannelsPerFrame = 1;
|
||||
fmt.mBitsPerChannel = 16;
|
||||
fmt.mBytesPerFrame = 2;
|
||||
fmt.mBytesPerPacket = 2;
|
||||
|
||||
ElAqState st;
|
||||
memset(&st, 0, sizeof(st));
|
||||
st.pcm = pcm;
|
||||
st.frames = frames;
|
||||
|
||||
AudioQueueRef q = NULL;
|
||||
/* NULL run loop => callbacks arrive on an AudioQueue-internal thread, so
|
||||
* this function can simply wait rather than having to pump a run loop it
|
||||
* does not own. El programs are not required to have one. */
|
||||
if (AudioQueueNewOutput(&fmt, el_aq_callback, &st, NULL, NULL, 0, &q) != noErr || !q) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
AudioQueueBufferRef bufs[EL_AQ_NBUF];
|
||||
int prepared = 0;
|
||||
for (int i = 0; i < EL_AQ_NBUF; i++) {
|
||||
if (AudioQueueAllocateBuffer(q, EL_AQ_FRAMES * sizeof(int16_t), &bufs[i]) != noErr) break;
|
||||
prepared++;
|
||||
}
|
||||
if (prepared == 0) { AudioQueueDispose(q, true); return 0; }
|
||||
|
||||
/* Prime: fill what we can before starting, so playback begins immediately
|
||||
* rather than after the first underrun. */
|
||||
for (int i = 0; i < prepared; i++) {
|
||||
int64_t remain = st.frames - st.pos;
|
||||
if (remain <= 0) break;
|
||||
int64_t n = remain < EL_AQ_FRAMES ? remain : EL_AQ_FRAMES;
|
||||
memcpy(bufs[i]->mAudioData, st.pcm + st.pos, (size_t)n * sizeof(int16_t));
|
||||
bufs[i]->mAudioDataByteSize = (UInt32)(n * (int64_t)sizeof(int16_t));
|
||||
st.pos += n;
|
||||
if (AudioQueueEnqueueBuffer(q, bufs[i], 0, NULL) != noErr) break;
|
||||
st.inflight++;
|
||||
}
|
||||
if (st.inflight == 0) { AudioQueueDispose(q, true); return 0; }
|
||||
|
||||
if (AudioQueueStart(q, NULL) != noErr) { AudioQueueDispose(q, true); return 0; }
|
||||
|
||||
/* Bound the wait by the material's own duration plus a margin. A speaker
|
||||
* that wedges a program is worse than a speaker that gives up. */
|
||||
double seconds = (double)frames / (double)sample_rate;
|
||||
int64_t max_us = (int64_t)((seconds + 5.0) * 1000000.0);
|
||||
int64_t waited = 0;
|
||||
const int64_t tick = 5000; /* 5 ms */
|
||||
while (!st.drained && waited < max_us) {
|
||||
usleep((useconds_t)tick);
|
||||
waited += tick;
|
||||
}
|
||||
|
||||
AudioQueueStop(q, true);
|
||||
AudioQueueDispose(q, true);
|
||||
return st.drained ? 1 : 0;
|
||||
}
|
||||
|
||||
/* ── El entry points ────────────────────────────────────────────────────────
|
||||
* Declared in el_runtime.h; see there for the El-facing contract. */
|
||||
|
||||
/* 1 when this build has a real speaker behind it. El code should ask before
|
||||
* speaking so the no-speaker case is a reported condition, not a silence that
|
||||
* looks like success. */
|
||||
el_val_t speaker_available(void) {
|
||||
return (el_val_t)1;
|
||||
}
|
||||
|
||||
el_val_t speaker_name(void) {
|
||||
return EL_STR("coreaudio-audioqueue");
|
||||
}
|
||||
|
||||
/* Play an El [Int] of 16-bit samples. Values are clamped, not wrapped: a
|
||||
* render that overshoots should distort at the rails the way real clipping
|
||||
* does, rather than invert phase and produce a sound nothing in the signal
|
||||
* chain intended. */
|
||||
el_val_t speaker_play_pcm16(el_val_t samples, el_val_t sample_rate) {
|
||||
int64_t n = (int64_t)el_list_len(samples);
|
||||
int32_t sr = (int32_t)sample_rate;
|
||||
if (n <= 0 || sr <= 0) return (el_val_t)0;
|
||||
|
||||
int16_t* pcm = (int16_t*)malloc((size_t)n * sizeof(int16_t));
|
||||
if (!pcm) return (el_val_t)0;
|
||||
|
||||
for (int64_t i = 0; i < n; i++) {
|
||||
int64_t v = (int64_t)el_list_get(samples, (el_val_t)i);
|
||||
if (v > 32767) v = 32767;
|
||||
if (v < -32768) v = -32768;
|
||||
pcm[i] = (int16_t)v;
|
||||
}
|
||||
|
||||
int ok = el_audio_play_raw(pcm, n, sr);
|
||||
free(pcm);
|
||||
return (el_val_t)(ok ? 1 : 0);
|
||||
}
|
||||
|
||||
/* ── Asynchronous playback ───────────────────────────────────────────────────
|
||||
*
|
||||
* converse needs this and a blocking play cannot give it. Barge-in means
|
||||
* stopping ON THE SPOT when the user starts talking — not at the end of the
|
||||
* current buffer, and certainly not at the end of the utterance. So the async
|
||||
* path keeps one queue alive, reports how far the hardware actually got, and
|
||||
* can be halted mid-buffer.
|
||||
*
|
||||
* `played_frames` is what makes an interrupted utterance resumable at the
|
||||
* sample rather than at the segment: it is the position the DAC reached, not
|
||||
* the position we enqueued to, and those differ by up to the full queue depth.
|
||||
*
|
||||
* One utterance at a time. A second async play stops the first — a mouth that
|
||||
* can say two things at once is not a feature. */
|
||||
|
||||
static AudioQueueRef g_aq = NULL;
|
||||
static ElAqState* g_aq_state = NULL;
|
||||
static int16_t* g_aq_pcm = NULL;
|
||||
static int32_t g_aq_sr = 0;
|
||||
|
||||
static void el_audio_teardown(void) {
|
||||
if (g_aq) {
|
||||
AudioQueueStop(g_aq, true);
|
||||
AudioQueueDispose(g_aq, true);
|
||||
g_aq = NULL;
|
||||
}
|
||||
free(g_aq_pcm); g_aq_pcm = NULL;
|
||||
free(g_aq_state); g_aq_state = NULL;
|
||||
g_aq_sr = 0;
|
||||
}
|
||||
|
||||
el_val_t speaker_play_pcm16_async(el_val_t samples, el_val_t sample_rate) {
|
||||
el_audio_teardown();
|
||||
|
||||
int64_t n = (int64_t)el_list_len(samples);
|
||||
int32_t sr = (int32_t)sample_rate;
|
||||
if (n <= 0 || sr <= 0) return (el_val_t)0;
|
||||
|
||||
g_aq_pcm = (int16_t*)malloc((size_t)n * sizeof(int16_t));
|
||||
if (!g_aq_pcm) return (el_val_t)0;
|
||||
for (int64_t i = 0; i < n; i++) {
|
||||
int64_t v = (int64_t)el_list_get(samples, (el_val_t)i);
|
||||
if (v > 32767) v = 32767;
|
||||
if (v < -32768) v = -32768;
|
||||
g_aq_pcm[i] = (int16_t)v;
|
||||
}
|
||||
|
||||
g_aq_state = (ElAqState*)calloc(1, sizeof(ElAqState));
|
||||
if (!g_aq_state) { el_audio_teardown(); return (el_val_t)0; }
|
||||
g_aq_state->pcm = g_aq_pcm;
|
||||
g_aq_state->frames = n;
|
||||
g_aq_sr = sr;
|
||||
|
||||
AudioStreamBasicDescription fmt;
|
||||
memset(&fmt, 0, sizeof(fmt));
|
||||
fmt.mSampleRate = (Float64)sr;
|
||||
fmt.mFormatID = kAudioFormatLinearPCM;
|
||||
fmt.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
|
||||
fmt.mFramesPerPacket = 1;
|
||||
fmt.mChannelsPerFrame = 1;
|
||||
fmt.mBitsPerChannel = 16;
|
||||
fmt.mBytesPerFrame = 2;
|
||||
fmt.mBytesPerPacket = 2;
|
||||
|
||||
if (AudioQueueNewOutput(&fmt, el_aq_callback, g_aq_state, NULL, NULL, 0, &g_aq) != noErr || !g_aq) {
|
||||
el_audio_teardown();
|
||||
return (el_val_t)0;
|
||||
}
|
||||
|
||||
for (int i = 0; i < EL_AQ_NBUF; i++) {
|
||||
int64_t remain = g_aq_state->frames - g_aq_state->pos;
|
||||
if (remain <= 0) break;
|
||||
AudioQueueBufferRef b = NULL;
|
||||
if (AudioQueueAllocateBuffer(g_aq, EL_AQ_FRAMES * sizeof(int16_t), &b) != noErr) break;
|
||||
int64_t k = remain < EL_AQ_FRAMES ? remain : EL_AQ_FRAMES;
|
||||
memcpy(b->mAudioData, g_aq_state->pcm + g_aq_state->pos, (size_t)k * sizeof(int16_t));
|
||||
b->mAudioDataByteSize = (UInt32)(k * (int64_t)sizeof(int16_t));
|
||||
g_aq_state->pos += k;
|
||||
if (AudioQueueEnqueueBuffer(g_aq, b, 0, NULL) != noErr) break;
|
||||
g_aq_state->inflight++;
|
||||
}
|
||||
if (g_aq_state->inflight == 0) { el_audio_teardown(); return (el_val_t)0; }
|
||||
|
||||
if (AudioQueueStart(g_aq, NULL) != noErr) { el_audio_teardown(); return (el_val_t)0; }
|
||||
return (el_val_t)1;
|
||||
}
|
||||
|
||||
el_val_t speaker_playing(void) {
|
||||
if (!g_aq || !g_aq_state) return (el_val_t)0;
|
||||
return (el_val_t)(g_aq_state->drained ? 0 : 1);
|
||||
}
|
||||
|
||||
/* Frames the DAC has actually rendered. AudioQueueGetCurrentTime's mSampleTime
|
||||
* is relative to queue start, which is exactly the "where was I really" figure
|
||||
* a resumable utterance needs. Falls back to the enqueued position if the
|
||||
* timeline is unavailable (it is, briefly, right after start). */
|
||||
el_val_t speaker_played_frames(void) {
|
||||
if (!g_aq || !g_aq_state) return (el_val_t)0;
|
||||
AudioTimeStamp ts;
|
||||
memset(&ts, 0, sizeof(ts));
|
||||
Boolean discontinuity = false;
|
||||
if (AudioQueueGetCurrentTime(g_aq, NULL, &ts, &discontinuity) == noErr &&
|
||||
(ts.mFlags & kAudioTimeStampSampleTimeValid)) {
|
||||
int64_t played = (int64_t)ts.mSampleTime;
|
||||
if (played < 0) played = 0;
|
||||
if (played > g_aq_state->frames) played = g_aq_state->frames;
|
||||
return (el_val_t)played;
|
||||
}
|
||||
return (el_val_t)g_aq_state->pos;
|
||||
}
|
||||
|
||||
/* Pause where we are, keeping the queue and its position intact.
|
||||
*
|
||||
* This is the difference between barge-in and "finish the buffer". The moment
|
||||
* the microphone hears speech, output must stop AT THAT SAMPLE — a listener
|
||||
* experiences even 200ms of continued talking as being talked over. Pause
|
||||
* rather than stop because the interruption might turn out to be a backchannel
|
||||
* ("mm-hm"), and the right response to a backchannel is to carry on as though
|
||||
* nothing happened, which requires the queue to still be exactly where it was.
|
||||
* A stop-and-restart would re-attack the buffer and be audible as a stutter. */
|
||||
el_val_t speaker_pause(void) {
|
||||
if (!g_aq) return (el_val_t)0;
|
||||
return (el_val_t)(AudioQueuePause(g_aq) == noErr ? 1 : 0);
|
||||
}
|
||||
|
||||
el_val_t speaker_resume(void) {
|
||||
if (!g_aq) return (el_val_t)0;
|
||||
return (el_val_t)(AudioQueueStart(g_aq, NULL) == noErr ? 1 : 0);
|
||||
}
|
||||
|
||||
el_val_t speaker_stop(void) {
|
||||
if (!g_aq) return (el_val_t)0;
|
||||
/* immediate: do NOT let the queue finish what it is holding */
|
||||
AudioQueueStop(g_aq, true);
|
||||
el_audio_teardown();
|
||||
return (el_val_t)1;
|
||||
}
|
||||
|
||||
/* Decode a 16-bit RIFF/WAVE into a freshly malloc'd mono int16 buffer.
|
||||
* Returns frames, or 0 on any failure; *out is set only on success. Shared by
|
||||
* the blocking and async WAV paths. */
|
||||
static int64_t el_wav_load(const char* path, int16_t** out, int32_t* out_sr) {
|
||||
if (!path || !out) return 0;
|
||||
FILE* f = fopen(path, "rb");
|
||||
if (!f) return 0;
|
||||
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return 0; }
|
||||
long size = ftell(f);
|
||||
if (size <= 44) { fclose(f); return 0; }
|
||||
rewind(f);
|
||||
unsigned char* d = (unsigned char*)malloc((size_t)size);
|
||||
if (!d) { fclose(f); return 0; }
|
||||
size_t got = fread(d, 1, (size_t)size, f);
|
||||
fclose(f);
|
||||
if (got != (size_t)size) { free(d); return 0; }
|
||||
if (memcmp(d, "RIFF", 4) != 0 || memcmp(d + 8, "WAVE", 4) != 0) { free(d); return 0; }
|
||||
|
||||
int32_t sr = 0, channels = 0, bits = 0;
|
||||
long dataOff = -1, dataLen = 0, o = 12;
|
||||
/* Chunk-walk rather than assuming fmt-then-data at fixed offsets: recorders
|
||||
* routinely interleave JUNK/FLLR padding, and a fixed-offset parser reads
|
||||
* padding as audio. */
|
||||
while (o + 8 <= size) {
|
||||
long sz = (long)d[o+4] | ((long)d[o+5] << 8) | ((long)d[o+6] << 16) | ((long)d[o+7] << 24);
|
||||
if (sz < 0) break;
|
||||
if (memcmp(d + o, "fmt ", 4) == 0 && o + 24 <= size) {
|
||||
channels = (int32_t)(d[o+10] | (d[o+11] << 8));
|
||||
sr = (int32_t)((long)d[o+12] | ((long)d[o+13] << 8) | ((long)d[o+14] << 16) | ((long)d[o+15] << 24));
|
||||
bits = (int32_t)(d[o+22] | (d[o+23] << 8));
|
||||
} else if (memcmp(d + o, "data", 4) == 0) {
|
||||
dataOff = o + 8;
|
||||
dataLen = sz;
|
||||
if (dataOff + dataLen > size) dataLen = size - dataOff;
|
||||
}
|
||||
o += 8 + sz + (sz & 1);
|
||||
}
|
||||
if (dataOff < 0 || sr <= 0 || bits != 16 || channels < 1 || dataLen <= 0) { free(d); return 0; }
|
||||
|
||||
long frames = dataLen / (2 * channels);
|
||||
int16_t* pcm = (int16_t*)malloc((size_t)frames * sizeof(int16_t));
|
||||
if (!pcm) { free(d); return 0; }
|
||||
for (long i = 0; i < frames; i++) {
|
||||
long b = dataOff + i * 2 * channels;
|
||||
pcm[i] = (int16_t)((unsigned)d[b] | ((unsigned)d[b+1] << 8));
|
||||
}
|
||||
free(d);
|
||||
*out = pcm;
|
||||
if (out_sr) *out_sr = sr;
|
||||
return (int64_t)frames;
|
||||
}
|
||||
|
||||
/* Async WAV playback. converse speaks PRE-RENDERED segments and must keep
|
||||
* listening while it does, so it needs the file on the queue without blocking
|
||||
* and needs to be able to stop it mid-buffer. Going through the file rather
|
||||
* than an El [Int] also avoids marshalling a million-element list per segment
|
||||
* for audio the caller never intends to look at. */
|
||||
el_val_t speaker_play_wav_async(el_val_t path) {
|
||||
const char* p = EL_CSTR(path);
|
||||
if (!p) return (el_val_t)0;
|
||||
|
||||
el_audio_teardown();
|
||||
|
||||
int32_t sr = 0;
|
||||
int16_t* pcm = NULL;
|
||||
int64_t frames = el_wav_load(p, &pcm, &sr);
|
||||
if (frames <= 0 || !pcm) { free(pcm); return (el_val_t)0; }
|
||||
|
||||
g_aq_pcm = pcm;
|
||||
g_aq_sr = sr;
|
||||
g_aq_state = (ElAqState*)calloc(1, sizeof(ElAqState));
|
||||
if (!g_aq_state) { el_audio_teardown(); return (el_val_t)0; }
|
||||
g_aq_state->pcm = g_aq_pcm;
|
||||
g_aq_state->frames = frames;
|
||||
|
||||
AudioStreamBasicDescription fmt;
|
||||
memset(&fmt, 0, sizeof(fmt));
|
||||
fmt.mSampleRate = (Float64)sr;
|
||||
fmt.mFormatID = kAudioFormatLinearPCM;
|
||||
fmt.mFormatFlags = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked;
|
||||
fmt.mFramesPerPacket = 1;
|
||||
fmt.mChannelsPerFrame = 1;
|
||||
fmt.mBitsPerChannel = 16;
|
||||
fmt.mBytesPerFrame = 2;
|
||||
fmt.mBytesPerPacket = 2;
|
||||
|
||||
if (AudioQueueNewOutput(&fmt, el_aq_callback, g_aq_state, NULL, NULL, 0, &g_aq) != noErr || !g_aq) {
|
||||
el_audio_teardown();
|
||||
return (el_val_t)0;
|
||||
}
|
||||
for (int i = 0; i < EL_AQ_NBUF; i++) {
|
||||
int64_t remain = g_aq_state->frames - g_aq_state->pos;
|
||||
if (remain <= 0) break;
|
||||
AudioQueueBufferRef b = NULL;
|
||||
if (AudioQueueAllocateBuffer(g_aq, EL_AQ_FRAMES * sizeof(int16_t), &b) != noErr) break;
|
||||
int64_t k = remain < EL_AQ_FRAMES ? remain : EL_AQ_FRAMES;
|
||||
memcpy(b->mAudioData, g_aq_state->pcm + g_aq_state->pos, (size_t)k * sizeof(int16_t));
|
||||
b->mAudioDataByteSize = (UInt32)(k * (int64_t)sizeof(int16_t));
|
||||
g_aq_state->pos += k;
|
||||
if (AudioQueueEnqueueBuffer(g_aq, b, 0, NULL) != noErr) break;
|
||||
g_aq_state->inflight++;
|
||||
}
|
||||
if (g_aq_state->inflight == 0) { el_audio_teardown(); return (el_val_t)0; }
|
||||
if (AudioQueueStart(g_aq, NULL) != noErr) { el_audio_teardown(); return (el_val_t)0; }
|
||||
return (el_val_t)1;
|
||||
}
|
||||
|
||||
/* Total frames and sample rate of a WAV, without playing it — wav-info, and the
|
||||
* duration converse needs to compute progress through a segment. */
|
||||
el_val_t wav_frames(el_val_t path) {
|
||||
const char* p = EL_CSTR(path);
|
||||
int16_t* pcm = NULL; int32_t sr = 0;
|
||||
int64_t n = el_wav_load(p, &pcm, &sr);
|
||||
free(pcm);
|
||||
return (el_val_t)n;
|
||||
}
|
||||
|
||||
el_val_t wav_rate(el_val_t path) {
|
||||
const char* p = EL_CSTR(path);
|
||||
int16_t* pcm = NULL; int32_t sr = 0;
|
||||
int64_t n = el_wav_load(p, &pcm, &sr);
|
||||
free(pcm);
|
||||
return (el_val_t)(n > 0 ? sr : 0);
|
||||
}
|
||||
|
||||
/* Play a 16-bit mono RIFF/WAVE file. Present because the render already knows
|
||||
* how to write a WAV and a caller may reasonably want to hear one back without
|
||||
* re-rendering it; the parse is deliberately minimal and chunk-walking, so the
|
||||
* JUNK/FLLR padding that recorders emit does not defeat it. */
|
||||
el_val_t speaker_play_wav(el_val_t path) {
|
||||
const char* p = EL_CSTR(path);
|
||||
if (!p) return (el_val_t)0;
|
||||
FILE* f = fopen(p, "rb");
|
||||
if (!f) return (el_val_t)0;
|
||||
|
||||
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return (el_val_t)0; }
|
||||
long size = ftell(f);
|
||||
if (size <= 44) { fclose(f); return (el_val_t)0; }
|
||||
rewind(f);
|
||||
|
||||
unsigned char* d = (unsigned char*)malloc((size_t)size);
|
||||
if (!d) { fclose(f); return (el_val_t)0; }
|
||||
size_t got = fread(d, 1, (size_t)size, f);
|
||||
fclose(f);
|
||||
if (got != (size_t)size) { free(d); return (el_val_t)0; }
|
||||
|
||||
if (memcmp(d, "RIFF", 4) != 0 || memcmp(d + 8, "WAVE", 4) != 0) { free(d); return (el_val_t)0; }
|
||||
|
||||
int32_t sr = 0, channels = 0, bits = 0;
|
||||
long dataOff = -1, dataLen = 0;
|
||||
long o = 12;
|
||||
while (o + 8 <= size) {
|
||||
long sz = (long)d[o+4] | ((long)d[o+5] << 8) | ((long)d[o+6] << 16) | ((long)d[o+7] << 24);
|
||||
if (sz < 0) break;
|
||||
if (memcmp(d + o, "fmt ", 4) == 0 && o + 24 <= size) {
|
||||
channels = (int32_t)(d[o+10] | (d[o+11] << 8));
|
||||
sr = (int32_t)((long)d[o+12] | ((long)d[o+13] << 8) | ((long)d[o+14] << 16) | ((long)d[o+15] << 24));
|
||||
bits = (int32_t)(d[o+22] | (d[o+23] << 8));
|
||||
} else if (memcmp(d + o, "data", 4) == 0) {
|
||||
dataOff = o + 8;
|
||||
dataLen = sz;
|
||||
if (dataOff + dataLen > size) dataLen = size - dataOff;
|
||||
}
|
||||
o += 8 + sz + (sz & 1);
|
||||
}
|
||||
if (dataOff < 0 || sr <= 0 || bits != 16 || channels < 1 || dataLen <= 0) { free(d); return (el_val_t)0; }
|
||||
|
||||
long frames = dataLen / (2 * channels);
|
||||
int16_t* pcm = (int16_t*)malloc((size_t)frames * sizeof(int16_t));
|
||||
if (!pcm) { free(d); return (el_val_t)0; }
|
||||
/* Take channel 0; the organ is mono by design and downmixing would be an
|
||||
* opinion about content this layer is not entitled to have. */
|
||||
for (long i = 0; i < frames; i++) {
|
||||
long b = dataOff + i * 2 * channels;
|
||||
pcm[i] = (int16_t)((unsigned)d[b] | ((unsigned)d[b+1] << 8));
|
||||
}
|
||||
free(d);
|
||||
|
||||
int ok = el_audio_play_raw(pcm, frames, sr);
|
||||
free(pcm);
|
||||
return (el_val_t)(ok ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
/* el_capture_darwin.m — the MICROPHONE and CAMERA realizers. El's afferent
|
||||
* organ on Darwin: the two entry points through which the world gets in.
|
||||
*
|
||||
* WHY THIS FILE EXISTS, AND WHY IT IS A REALIZER RATHER THAN PURE EL.
|
||||
*
|
||||
* el_audio_darwin.m argued the efferent half of this: El can compute a sound
|
||||
* but it cannot make one, because "the DAC has now played these samples" is not
|
||||
* a fact any amount of arithmetic can produce. This file is the same argument
|
||||
* run backwards. El can compute *about* a sound — it can window it, take its
|
||||
* autocorrelation, run Levinson-Durbin over that, find the formant peaks in the
|
||||
* resulting all-pole envelope, and hand back a voiceprint — but it cannot ASK.
|
||||
* There is no expression in El, and there must not be, whose value is "the next
|
||||
* 1024 frames the microphone hears" or "what the camera is pointed at right
|
||||
* now." Those are not computed; they are *requested*, from an operating system
|
||||
* that owns the device, mediates consent for it, and delivers the answer on a
|
||||
* thread of its choosing whenever it feels like it. Asking is the one primitive
|
||||
* operation here. Everything else in this file is bookkeeping around the ask.
|
||||
*
|
||||
* So the line is drawn exactly where el_audio_darwin.m drew it, at the sample
|
||||
* buffer, and it is drawn on purpose:
|
||||
*
|
||||
* BELOW the line (here): open the device, honour the OS permission gate,
|
||||
* install a tap or a frame delegate, convert whatever the hardware happens to
|
||||
* emit into the one shape El asked for, and hand it up. No opinions about
|
||||
* content. No analysis. No decisions.
|
||||
*
|
||||
* ABOVE the line (El): energy, zero-crossing rate, spectral centroid, F0 by
|
||||
* autocorrelation, LPC, formants F1-F5, the compact descriptors, the
|
||||
* scene-geometry grid, the yield-or-hold turn-taking decision. All of it is
|
||||
* arithmetic over a buffer, all of it belongs in El, and none of it appears
|
||||
* below. The reference this file ports — peripheral/src/periph.swift — held
|
||||
* both halves, and that was the problem worth fixing: the descriptors were
|
||||
* trapped in a 939-line binary standing next to the language instead of being
|
||||
* written in it. Porting the *whole* of periph.swift down here would have
|
||||
* reproduced that mistake in C. Only the ask came down.
|
||||
*
|
||||
* The precedent for the file's SHAPE is eg_cosine_batch_strategy_metal_hand.m:
|
||||
* a platform-bound capability compiled as its own translation unit, declared in
|
||||
* el_runtime.h, linked in where the platform supports it, and deliberately NOT
|
||||
* a patch to the middle of el_runtime.c. Acquiring a device must not mean
|
||||
* editing the language, for the same reason acquiring a modality must not (see
|
||||
* the realizer registry: organs are resolved by name). el_peripheral_null.c is
|
||||
* the same entry points everywhere else, so El code that listens still links on
|
||||
* every platform and merely reports having no ear.
|
||||
*
|
||||
* FAIL CLOSED, ALWAYS.
|
||||
*
|
||||
* A capture path that returns plausible-looking zeros when it was denied is
|
||||
* worse than one that returns nothing, because the caller cannot tell the
|
||||
* difference between a silent room and a refused microphone. Every entry point
|
||||
* here checks AVCaptureDevice's authorization status BEFORE touching hardware
|
||||
* and returns the empty value — an empty list, a 0 map — on anything short of
|
||||
* .authorized. mic_available() and camera_available() report that state WITHOUT
|
||||
* prompting, so El can ask "may I?" without the act of asking being a prompt.
|
||||
*
|
||||
* NEVER HANG.
|
||||
*
|
||||
* Every wait in this file is bounded: 30s on a permission prompt (the user has
|
||||
* to walk to the dialog), seconds+5 on a capture of `seconds`, 10s on a camera
|
||||
* frame. An organ that wedges the program holding it is not an organ, it is a
|
||||
* fault. Every path also tears the device down on the way out, including the
|
||||
* failure paths, so a timed-out capture does not leave the mic light on.
|
||||
*
|
||||
* OWN-CORE AND LOCAL BY CONSTRUCTION.
|
||||
*
|
||||
* AVFoundation, CoreVideo, CoreGraphics and ImageIO ship with macOS. There is
|
||||
* no third-party library here, no model, and — the part that matters — no
|
||||
* network path of any kind. Samples and pixels move from local hardware into an
|
||||
* El value and stop. periph.swift had a URLSession in it; this file has no
|
||||
* socket, no URL, and nothing that could grow one without being obvious in
|
||||
* review. Consent is enforced above this layer in El and below it by the OS;
|
||||
* this layer's whole contribution to that is refusing to proceed.
|
||||
*
|
||||
* DISCLOSURE.
|
||||
*
|
||||
* Every actual device touch writes one line to stderr and flushes it, before
|
||||
* the device opens. stderr and not stdout: a program that announces "I am about
|
||||
* to open the microphone" on stdout has corrupted its own output, and the
|
||||
* caller must be able to separate the answer from how it was obtained. One line
|
||||
* per touch, no more — a disclosure rail that spams is a rail people learn to
|
||||
* ignore.
|
||||
*/
|
||||
|
||||
#if defined(__APPLE__)
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <CoreMedia/CoreMedia.h>
|
||||
#import <CoreVideo/CoreVideo.h>
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <ImageIO/ImageIO.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <unistd.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include "el_runtime.h"
|
||||
|
||||
/* ── Disclosure ──────────────────────────────────────────────────────────────
|
||||
* One flushed line per real device touch, on stderr. Flushed rather than
|
||||
* buffered so the line reaches the terminal BEFORE the mic light comes on
|
||||
* rather than whenever the buffer happens to drain. */
|
||||
static void el_cap_disclose(const char* what) {
|
||||
fprintf(stderr, " [peripheral] %s\n", what);
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
/* ── Permission ──────────────────────────────────────────────────────────────
|
||||
* authorizationStatus is a pure read of the TCC database: it never prompts and
|
||||
* never blocks, which is what lets mic_available()/camera_available() answer
|
||||
* honestly without the question itself becoming an event. requestAccess DOES
|
||||
* prompt, so it lives behind its own entry point and nothing calls it
|
||||
* implicitly. */
|
||||
|
||||
static int el_cap_authorized(AVMediaType media) {
|
||||
@try {
|
||||
return [AVCaptureDevice authorizationStatusForMediaType:media]
|
||||
== AVAuthorizationStatusAuthorized ? 1 : 0;
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static int el_cap_device_present(AVMediaType media) {
|
||||
@try {
|
||||
return [AVCaptureDevice defaultDeviceWithMediaType:media] != nil ? 1 : 0;
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt once and wait, bounded. 30 seconds is the same budget periph.swift
|
||||
* used: long enough for a human to notice a dialog and decide, short enough
|
||||
* that an unattended run fails rather than parks forever. A timeout is reported
|
||||
* as "not granted", which is the safe reading — we genuinely do not know that
|
||||
* it was. */
|
||||
static int el_cap_request(AVMediaType media) {
|
||||
__block int granted = 0;
|
||||
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
||||
@try {
|
||||
[AVCaptureDevice requestAccessForMediaType:media
|
||||
completionHandler:^(BOOL ok) {
|
||||
granted = ok ? 1 : 0;
|
||||
dispatch_semaphore_signal(sem);
|
||||
}];
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
return 0;
|
||||
}
|
||||
if (dispatch_semaphore_wait(sem,
|
||||
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC))) != 0) {
|
||||
return 0; /* timed out — treat as refused */
|
||||
}
|
||||
return granted;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* MICROPHONE — one-shot capture
|
||||
* ══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* The sink the tap block writes into. An object rather than a static so two
|
||||
* captures can never share state, and so ARC keeps it alive for exactly as long
|
||||
* as the block that captured it. The lock is real, not decorative: the tap runs
|
||||
* on an AVAudioEngine-internal thread and the waiter runs on the caller's. */
|
||||
@interface ElCapMicSink : NSObject
|
||||
@property (nonatomic, strong) NSMutableData* pcm;
|
||||
@property (nonatomic, strong) NSLock* lock;
|
||||
@end
|
||||
|
||||
@implementation ElCapMicSink
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_pcm = [NSMutableData data];
|
||||
_lock = [[NSLock alloc] init];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@end
|
||||
|
||||
/* Capture `seconds` of mono 16-bit PCM at `sample_rate`.
|
||||
*
|
||||
* The hardware format is NOT assumed. A built-in mic will typically hand back
|
||||
* float32 at 44.1 or 48 kHz, an aggregate device may be 8 channels at 96 kHz,
|
||||
* and a caller asking for 16 kHz mono (which is what the formant path wants)
|
||||
* gets 16 kHz mono either way. AVAudioConverter does the rate conversion and
|
||||
* the downmix; doing it by hand would mean writing a resampler in the one file
|
||||
* that is supposed to contain no arithmetic.
|
||||
*
|
||||
* The converter is built ONCE, outside the tap, because a sample-rate converter
|
||||
* carries filter state across buffers — rebuilding it per callback would put a
|
||||
* discontinuity at every buffer boundary, which is audible and which would then
|
||||
* show up in El's spectral descriptors as energy that was never in the room. */
|
||||
el_val_t mic_capture_pcm16(el_val_t seconds, el_val_t sample_rate) {
|
||||
el_val_t empty = el_list_empty();
|
||||
|
||||
if (!el_cap_authorized(AVMediaTypeAudio)) return empty;
|
||||
|
||||
int64_t secs = (int64_t)seconds;
|
||||
int64_t sr = (int64_t)sample_rate;
|
||||
if (secs <= 0 || sr <= 0) return empty;
|
||||
/* Bound the ask. A caller that asks for a year of audio has made a mistake,
|
||||
* and honouring it would mean an unkillable capture and an OOM. */
|
||||
if (secs > 300) secs = 300;
|
||||
if (sr > 384000) sr = 384000;
|
||||
|
||||
__block AVAudioEngine* engine = nil;
|
||||
AVAudioInputNode* input = nil;
|
||||
AVAudioFormat* hwFmt = nil;
|
||||
int tapped = 0;
|
||||
|
||||
@try {
|
||||
engine = [[AVAudioEngine alloc] init];
|
||||
input = [engine inputNode];
|
||||
hwFmt = [input inputFormatForBus:0];
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
return empty;
|
||||
}
|
||||
if (!input || !hwFmt || hwFmt.sampleRate <= 0 || hwFmt.channelCount == 0) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
/* Preferred target: mono int16 at the requested rate. If the converter
|
||||
* refuses that pairing (some exotic input layouts will not downmix), fall
|
||||
* back to keeping the hardware's channel count and taking channel 0 on the
|
||||
* way out — the organ is mono by design and inventing a downmix here would
|
||||
* be an opinion about content this layer is not entitled to have. */
|
||||
AVAudioFormat* outFmt =
|
||||
[[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatInt16
|
||||
sampleRate:(double)sr
|
||||
channels:1
|
||||
interleaved:YES];
|
||||
AVAudioConverter* conv = outFmt ? [[AVAudioConverter alloc] initFromFormat:hwFmt
|
||||
toFormat:outFmt] : nil;
|
||||
AVAudioChannelCount outCh = 1;
|
||||
if (!conv) {
|
||||
outFmt = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatInt16
|
||||
sampleRate:(double)sr
|
||||
channels:hwFmt.channelCount
|
||||
interleaved:YES];
|
||||
conv = outFmt ? [[AVAudioConverter alloc] initFromFormat:hwFmt toFormat:outFmt] : nil;
|
||||
outCh = hwFmt.channelCount;
|
||||
}
|
||||
if (!conv || !outFmt) return empty;
|
||||
|
||||
ElCapMicSink* sink = [[ElCapMicSink alloc] init];
|
||||
const int64_t want = secs * sr; /* frames we are waiting for */
|
||||
|
||||
{
|
||||
char msg[192];
|
||||
snprintf(msg, sizeof(msg),
|
||||
"MIC: opening the microphone for %llds -> %lld Hz mono PCM "
|
||||
"(local, never egresses).", (long long)secs, (long long)sr);
|
||||
el_cap_disclose(msg);
|
||||
}
|
||||
|
||||
const double ratio = (double)sr / hwFmt.sampleRate;
|
||||
|
||||
@try {
|
||||
[input installTapOnBus:0
|
||||
bufferSize:4096
|
||||
format:hwFmt
|
||||
block:^(AVAudioPCMBuffer* _Nonnull buf, AVAudioTime* _Nonnull when) {
|
||||
(void)when;
|
||||
if (!buf || buf.frameLength == 0) return;
|
||||
|
||||
AVAudioFrameCount cap =
|
||||
(AVAudioFrameCount)((double)buf.frameLength * ratio) + 1024;
|
||||
AVAudioPCMBuffer* out =
|
||||
[[AVAudioPCMBuffer alloc] initWithPCMFormat:outFmt frameCapacity:cap];
|
||||
if (!out) return;
|
||||
|
||||
__block BOOL fed = NO;
|
||||
AVAudioConverterInputBlock feed =
|
||||
^AVAudioBuffer* _Nullable (AVAudioPacketCount need,
|
||||
AVAudioConverterInputStatus* _Nonnull status) {
|
||||
(void)need;
|
||||
if (fed) { *status = AVAudioConverterInputStatus_NoDataNow; return nil; }
|
||||
fed = YES;
|
||||
*status = AVAudioConverterInputStatus_HaveData;
|
||||
return buf;
|
||||
};
|
||||
|
||||
NSError* err = nil;
|
||||
AVAudioConverterOutputStatus st =
|
||||
[conv convertToBuffer:out error:&err withInputFromBlock:feed];
|
||||
if (st == AVAudioConverterOutputStatus_Error || out.frameLength == 0) return;
|
||||
|
||||
const int16_t* src = out.int16ChannelData ? out.int16ChannelData[0] : NULL;
|
||||
if (!src) return;
|
||||
|
||||
NSUInteger n = (NSUInteger)out.frameLength;
|
||||
[sink.lock lock];
|
||||
if (outCh == 1) {
|
||||
[sink.pcm appendBytes:src length:n * sizeof(int16_t)];
|
||||
} else {
|
||||
/* Interleaved: stride to channel 0. */
|
||||
for (NSUInteger i = 0; i < n; i++) {
|
||||
int16_t v = src[i * outCh];
|
||||
[sink.pcm appendBytes:&v length:sizeof(int16_t)];
|
||||
}
|
||||
}
|
||||
[sink.lock unlock];
|
||||
}];
|
||||
tapped = 1;
|
||||
|
||||
[engine prepare];
|
||||
NSError* startErr = nil;
|
||||
if (![engine startAndReturnError:&startErr]) {
|
||||
[input removeTapOnBus:0];
|
||||
return empty;
|
||||
}
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
@try { if (tapped) [input removeTapOnBus:0]; } @catch (NSException* e2) { (void)e2; }
|
||||
@try { [engine stop]; } @catch (NSException* e2) { (void)e2; }
|
||||
return empty;
|
||||
}
|
||||
|
||||
/* Wait for `want` frames, bounded by the material's own duration plus a
|
||||
* margin. A device that stops producing must not become a hang. */
|
||||
const int64_t deadline_us = (secs + 5) * 1000000;
|
||||
int64_t waited_us = 0;
|
||||
const int64_t tick_us = 5000;
|
||||
for (;;) {
|
||||
[sink.lock lock];
|
||||
int64_t have = (int64_t)([sink.pcm length] / sizeof(int16_t));
|
||||
[sink.lock unlock];
|
||||
if (have >= want || waited_us >= deadline_us) break;
|
||||
usleep((useconds_t)tick_us);
|
||||
waited_us += tick_us;
|
||||
}
|
||||
|
||||
@try { [input removeTapOnBus:0]; } @catch (NSException* e) { (void)e; }
|
||||
@try { [engine stop]; } @catch (NSException* e) { (void)e; }
|
||||
engine = nil;
|
||||
|
||||
/* Hand up exactly what was asked for, or everything we got if the device
|
||||
* came up short. Never padded: silence we invented is indistinguishable
|
||||
* from silence we heard, and El has no way to tell them apart afterwards. */
|
||||
[sink.lock lock];
|
||||
int64_t have = (int64_t)([sink.pcm length] / sizeof(int16_t));
|
||||
int64_t n = have < want ? have : want;
|
||||
const int16_t* pcm = (const int16_t*)[sink.pcm bytes];
|
||||
el_val_t list = empty;
|
||||
for (int64_t i = 0; i < n; i++) {
|
||||
list = el_list_append(list, (el_val_t)(int64_t)pcm[i]);
|
||||
}
|
||||
[sink.lock unlock];
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
el_val_t mic_available(void) {
|
||||
if (!el_cap_authorized(AVMediaTypeAudio)) return (el_val_t)0;
|
||||
return (el_val_t)(el_cap_device_present(AVMediaTypeAudio) ? 1 : 0);
|
||||
}
|
||||
|
||||
el_val_t mic_request_access(void) {
|
||||
return (el_val_t)(el_cap_request(AVMediaTypeAudio) ? 1 : 0);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* MICROPHONE — live monitor (the full-duplex ear)
|
||||
*
|
||||
* converse needs to keep listening WHILE it speaks, which means the mic is open
|
||||
* at the same time as the speaker. In a real room that is a feedback path:
|
||||
* without cancellation Neuron hears its own voice, decides someone is talking,
|
||||
* and barges in on itself. setVoiceProcessingEnabled: hands the input node to
|
||||
* the OS voice-processing unit, which subtracts the known output signal from
|
||||
* the input — the single thing that makes barge-in work outside a headset.
|
||||
*
|
||||
* It is not always available (some aggregate and virtual devices refuse it), so
|
||||
* failure to enable it is reported as a DISTINCT return value (2) rather than
|
||||
* folded into success. The caller needs to know, because the correct response
|
||||
* is to raise the VAD floor, and a caller that thinks AEC is on will set that
|
||||
* floor far too low.
|
||||
*
|
||||
* The tap keeps only a running short-window RMS in a static behind a mutex.
|
||||
* Deliberately not a queue of samples: this path is polled at ~50 Hz by a loop
|
||||
* that only ever asks "is someone talking", and buffering audio nobody reads
|
||||
* would be an unbounded allocation in the middle of a conversation.
|
||||
* ══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
static AVAudioEngine* g_mon_engine = nil;
|
||||
static int g_mon_running = 0;
|
||||
static int g_mon_code = 0; /* what the successful start reported */
|
||||
static double g_mon_rms = 0.0;
|
||||
static pthread_mutex_t g_mon_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
el_val_t mic_monitor_start(void) {
|
||||
/* Idempotent, and it re-reports the ORIGINAL code rather than a bare 1: a
|
||||
* caller that starts twice must not be told AEC is on when the first start
|
||||
* already discovered it was not. */
|
||||
if (g_mon_running) return (el_val_t)g_mon_code;
|
||||
if (!el_cap_authorized(AVMediaTypeAudio)) return (el_val_t)0;
|
||||
|
||||
AVAudioEngine* engine = nil;
|
||||
AVAudioInputNode* input = nil;
|
||||
AVAudioFormat* fmt = nil;
|
||||
int aec = 0;
|
||||
int tapped = 0;
|
||||
|
||||
@try {
|
||||
engine = [[AVAudioEngine alloc] init];
|
||||
input = [engine inputNode];
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
return (el_val_t)0;
|
||||
}
|
||||
if (!input) return (el_val_t)0;
|
||||
|
||||
/* Enable AEC BEFORE reading the format: the voice-processing unit imposes
|
||||
* its own input format, and a tap installed with the pre-VP format would be
|
||||
* rejected at start. */
|
||||
@try {
|
||||
NSError* vpErr = nil;
|
||||
if ([input respondsToSelector:@selector(setVoiceProcessingEnabled:error:)]) {
|
||||
aec = [input setVoiceProcessingEnabled:YES error:&vpErr] ? 1 : 0;
|
||||
}
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
aec = 0;
|
||||
}
|
||||
|
||||
@try {
|
||||
fmt = [input inputFormatForBus:0];
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
return (el_val_t)0;
|
||||
}
|
||||
if (!fmt || fmt.sampleRate <= 0 || fmt.channelCount == 0) return (el_val_t)0;
|
||||
|
||||
el_cap_disclose(aec
|
||||
? "MIC: opening the microphone for live monitoring, echo-cancelled (local)."
|
||||
: "MIC: opening the microphone for live monitoring, NO echo cancellation (local).");
|
||||
|
||||
@try {
|
||||
[input installTapOnBus:0
|
||||
bufferSize:1024
|
||||
format:fmt
|
||||
block:^(AVAudioPCMBuffer* _Nonnull buf, AVAudioTime* _Nonnull when) {
|
||||
(void)when;
|
||||
if (!buf) return;
|
||||
AVAudioFrameCount n = buf.frameLength;
|
||||
if (n == 0) return;
|
||||
|
||||
double sum = 0.0;
|
||||
/* Whatever the VP unit hands back — float32 is the norm, int16 and
|
||||
* int32 are possible on odd hardware — normalise to -1..1 so the
|
||||
* Float El sees means the same thing on every device. */
|
||||
if (buf.floatChannelData) {
|
||||
const float* ch = buf.floatChannelData[0];
|
||||
for (AVAudioFrameCount i = 0; i < n; i++) sum += (double)ch[i] * (double)ch[i];
|
||||
} else if (buf.int16ChannelData) {
|
||||
const int16_t* ch = buf.int16ChannelData[0];
|
||||
for (AVAudioFrameCount i = 0; i < n; i++) {
|
||||
double v = (double)ch[i] / 32768.0;
|
||||
sum += v * v;
|
||||
}
|
||||
} else if (buf.int32ChannelData) {
|
||||
const int32_t* ch = buf.int32ChannelData[0];
|
||||
for (AVAudioFrameCount i = 0; i < n; i++) {
|
||||
double v = (double)ch[i] / 2147483648.0;
|
||||
sum += v * v;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
double rms = sqrt(sum / (double)n);
|
||||
if (rms < 0.0) rms = 0.0;
|
||||
if (rms > 1.0) rms = 1.0;
|
||||
|
||||
pthread_mutex_lock(&g_mon_lock);
|
||||
g_mon_rms = rms;
|
||||
pthread_mutex_unlock(&g_mon_lock);
|
||||
}];
|
||||
tapped = 1;
|
||||
|
||||
[engine prepare];
|
||||
NSError* startErr = nil;
|
||||
if (![engine startAndReturnError:&startErr]) {
|
||||
[input removeTapOnBus:0];
|
||||
return (el_val_t)0;
|
||||
}
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
@try { if (tapped) [input removeTapOnBus:0]; } @catch (NSException* e2) { (void)e2; }
|
||||
@try { [engine stop]; } @catch (NSException* e2) { (void)e2; }
|
||||
return (el_val_t)0;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_mon_lock);
|
||||
g_mon_rms = 0.0;
|
||||
pthread_mutex_unlock(&g_mon_lock);
|
||||
|
||||
g_mon_engine = engine;
|
||||
g_mon_running = 1;
|
||||
g_mon_code = aec ? 1 : 2;
|
||||
return (el_val_t)g_mon_code;
|
||||
}
|
||||
|
||||
/* Float in 0..1. Reads the last window the tap computed; never blocks on the
|
||||
* audio thread beyond the mutex, because this is polled inside a turn-taking
|
||||
* loop where a stall IS a missed barge-in. */
|
||||
el_val_t mic_monitor_rms(void) {
|
||||
double rms = 0.0;
|
||||
pthread_mutex_lock(&g_mon_lock);
|
||||
rms = g_mon_rms;
|
||||
pthread_mutex_unlock(&g_mon_lock);
|
||||
return el_from_float(rms);
|
||||
}
|
||||
|
||||
el_val_t mic_monitor_stop(void) {
|
||||
AVAudioEngine* engine = g_mon_engine;
|
||||
g_mon_engine = nil;
|
||||
g_mon_running = 0;
|
||||
g_mon_code = 0;
|
||||
|
||||
if (engine) {
|
||||
@try { [[engine inputNode] removeTapOnBus:0]; } @catch (NSException* e) { (void)e; }
|
||||
@try { [engine stop]; } @catch (NSException* e) { (void)e; }
|
||||
}
|
||||
pthread_mutex_lock(&g_mon_lock);
|
||||
g_mon_rms = 0.0;
|
||||
pthread_mutex_unlock(&g_mon_lock);
|
||||
return (el_val_t)1;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
* CAMERA
|
||||
* ══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
static void el_cap_free_bitmap(void* info, const void* data, size_t size) {
|
||||
(void)info; (void)size;
|
||||
free((void*)data);
|
||||
}
|
||||
|
||||
/* CVPixelBuffer -> CGImage, own-core, no CoreImage.
|
||||
*
|
||||
* The output is pinned to 32BGRA at the AVCaptureVideoDataOutput (see below)
|
||||
* precisely so this conversion can be a memcpy and a CGImageCreate. The
|
||||
* alternative — accepting the camera's native 2vuy/420v and colour-converting
|
||||
* here — would mean either pulling in CoreImage or writing a YUV->RGB matrix in
|
||||
* the file that is supposed to contain no arithmetic. Asking the capture output
|
||||
* for BGRA moves that work into AVFoundation, where it is already written and
|
||||
* already hardware-accelerated.
|
||||
*
|
||||
* The rows are copied out rather than aliased because the CVPixelBuffer is
|
||||
* recycled by the capture session the moment the delegate returns; a CGImage
|
||||
* pointing at it would be pointing at the NEXT frame by the time anyone looked. */
|
||||
static CGImageRef el_cap_cgimage_from_pixelbuffer(CVPixelBufferRef pb) {
|
||||
if (!pb) return NULL;
|
||||
if (CVPixelBufferGetPixelFormatType(pb) != kCVPixelFormatType_32BGRA) return NULL;
|
||||
if (CVPixelBufferLockBaseAddress(pb, kCVPixelBufferLock_ReadOnly) != kCVReturnSuccess) return NULL;
|
||||
|
||||
size_t w = CVPixelBufferGetWidth(pb);
|
||||
size_t h = CVPixelBufferGetHeight(pb);
|
||||
size_t src_bpr = CVPixelBufferGetBytesPerRow(pb);
|
||||
const uint8_t* base = (const uint8_t*)CVPixelBufferGetBaseAddress(pb);
|
||||
|
||||
CGImageRef img = NULL;
|
||||
if (base && w > 0 && h > 0 && src_bpr >= w * 4) {
|
||||
size_t dst_bpr = w * 4;
|
||||
uint8_t* copy = (uint8_t*)malloc(dst_bpr * h);
|
||||
if (copy) {
|
||||
for (size_t y = 0; y < h; y++) {
|
||||
memcpy(copy + y * dst_bpr, base + y * src_bpr, dst_bpr);
|
||||
}
|
||||
CGDataProviderRef dp =
|
||||
CGDataProviderCreateWithData(NULL, copy, dst_bpr * h, el_cap_free_bitmap);
|
||||
if (dp) {
|
||||
CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
|
||||
if (cs) {
|
||||
img = CGImageCreate(w, h, 8, 32, dst_bpr, cs,
|
||||
(CGBitmapInfo)(kCGBitmapByteOrder32Little |
|
||||
kCGImageAlphaNoneSkipFirst),
|
||||
dp, NULL, false, kCGRenderingIntentDefault);
|
||||
CGColorSpaceRelease(cs);
|
||||
}
|
||||
CGDataProviderRelease(dp); /* provider owns `copy` from here */
|
||||
} else {
|
||||
free(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CVPixelBufferUnlockBaseAddress(pb, kCVPixelBufferLock_ReadOnly);
|
||||
return img;
|
||||
}
|
||||
|
||||
/* The frame delegate. AVCaptureVideoDataOutput is used rather than
|
||||
* AVCapturePhotoOutput for the same reason periph.swift used it: the photo path
|
||||
* wants KVO and a session owned by an app object, and this runs in a plain CLI
|
||||
* process with no run loop it can assume. A data output just calls back.
|
||||
*
|
||||
* The first frames are dropped on purpose. A camera that has just been powered
|
||||
* on is still converging exposure and white balance, and the first frame is
|
||||
* reliably darker and greener than the room. El's scene-geometry descriptors
|
||||
* are brightness and mean-colour statistics, so handing up an unsettled frame
|
||||
* would not produce a slightly worse answer, it would produce a confidently
|
||||
* wrong one. */
|
||||
@interface ElCapFrameGrabber : NSObject <AVCaptureVideoDataOutputSampleBufferDelegate> {
|
||||
CGImageRef _img;
|
||||
int _seen;
|
||||
dispatch_semaphore_t _sem;
|
||||
}
|
||||
- (dispatch_semaphore_t)sem;
|
||||
- (CGImageRef)takeImage; /* transfers ownership to the caller */
|
||||
@end
|
||||
|
||||
@implementation ElCapFrameGrabber
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_img = NULL;
|
||||
_seen = 0;
|
||||
_sem = dispatch_semaphore_create(0);
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (dispatch_semaphore_t)sem { return _sem; }
|
||||
|
||||
- (CGImageRef)takeImage {
|
||||
CGImageRef out = _img;
|
||||
_img = NULL;
|
||||
return out;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_img) { CGImageRelease(_img); _img = NULL; }
|
||||
}
|
||||
|
||||
/* Runs on the serial delegate queue, so no lock is needed among callbacks; the
|
||||
* waiter only reads _img after the semaphore has been signalled AND the session
|
||||
* has been stopped, which orders it after the last callback. */
|
||||
- (void)captureOutput:(AVCaptureOutput*)output
|
||||
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
||||
fromConnection:(AVCaptureConnection*)connection {
|
||||
(void)output; (void)connection;
|
||||
_seen++;
|
||||
if (_img != NULL || _seen < 5) return; /* let exposure settle */
|
||||
|
||||
CVImageBufferRef pb = CMSampleBufferGetImageBuffer(sampleBuffer);
|
||||
if (!pb) return;
|
||||
CGImageRef img = el_cap_cgimage_from_pixelbuffer(pb);
|
||||
if (!img) return;
|
||||
_img = img;
|
||||
dispatch_semaphore_signal(_sem);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
/* Bring the camera up, take exactly one settled frame, put it back down.
|
||||
* Returns a +1 CGImageRef the caller releases, or NULL. Bounded at 10s: a
|
||||
* camera held by another process, or one whose TCC grant was revoked between
|
||||
* the check and the open, must fail rather than park. */
|
||||
static CGImageRef el_cap_grab_frame(void) {
|
||||
AVCaptureSession* session = nil;
|
||||
AVCaptureVideoDataOutput* output = nil;
|
||||
ElCapFrameGrabber* grabber = nil;
|
||||
CGImageRef img = NULL;
|
||||
|
||||
@try {
|
||||
AVCaptureDevice* dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
|
||||
if (!dev) return NULL;
|
||||
|
||||
NSError* err = nil;
|
||||
AVCaptureDeviceInput* in = [AVCaptureDeviceInput deviceInputWithDevice:dev error:&err];
|
||||
if (!in) return NULL;
|
||||
|
||||
session = [[AVCaptureSession alloc] init];
|
||||
session.sessionPreset = AVCaptureSessionPresetPhoto;
|
||||
if (![session canAddInput:in]) return NULL;
|
||||
[session addInput:in];
|
||||
|
||||
output = [[AVCaptureVideoDataOutput alloc] init];
|
||||
output.alwaysDiscardsLateVideoFrames = YES;
|
||||
/* Pin the pixel format so the CGImage conversion above stays a memcpy.
|
||||
* Every macOS capture device advertises 32BGRA. */
|
||||
output.videoSettings = @{ (id)kCVPixelBufferPixelFormatTypeKey :
|
||||
@(kCVPixelFormatType_32BGRA) };
|
||||
|
||||
grabber = [[ElCapFrameGrabber alloc] init];
|
||||
dispatch_queue_t q = dispatch_queue_create("el.capture.camera", DISPATCH_QUEUE_SERIAL);
|
||||
[output setSampleBufferDelegate:grabber queue:q];
|
||||
|
||||
if (![session canAddOutput:output]) return NULL;
|
||||
[session addOutput:output];
|
||||
|
||||
el_cap_disclose("CAMERA: opening the camera for one frame (local, never egresses).");
|
||||
[session startRunning];
|
||||
} @catch (NSException* e) {
|
||||
(void)e;
|
||||
@try { [session stopRunning]; } @catch (NSException* e2) { (void)e2; }
|
||||
return NULL;
|
||||
}
|
||||
|
||||
long timed_out = dispatch_semaphore_wait([grabber sem],
|
||||
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(10 * NSEC_PER_SEC)));
|
||||
|
||||
/* Stop first, then detach the delegate, then read. In that order the last
|
||||
* callback has already returned by the time anyone touches the image. */
|
||||
@try { [session stopRunning]; } @catch (NSException* e) { (void)e; }
|
||||
@try { [output setSampleBufferDelegate:nil queue:NULL]; } @catch (NSException* e) { (void)e; }
|
||||
|
||||
if (timed_out == 0) img = [grabber takeImage];
|
||||
return img;
|
||||
}
|
||||
|
||||
el_val_t camera_available(void) {
|
||||
if (!el_cap_authorized(AVMediaTypeVideo)) return (el_val_t)0;
|
||||
return (el_val_t)(el_cap_device_present(AVMediaTypeVideo) ? 1 : 0);
|
||||
}
|
||||
|
||||
el_val_t camera_request_access(void) {
|
||||
return (el_val_t)(el_cap_request(AVMediaTypeVideo) ? 1 : 0);
|
||||
}
|
||||
|
||||
/* Longest edge of the grid handed to El. 64 is not a resolution, it is a budget:
|
||||
* a 1920x1080 frame is 6.2 MILLION packed RGB ints, and building that as an El
|
||||
* list would cost more time and memory than everything El then does with it.
|
||||
* The descriptors El computes over this — mean colour, brightness, a 3x3
|
||||
* luminance grid — are region statistics, and region statistics do not get
|
||||
* meaningfully better above a 64-wide grid. The TRUE frame dimensions are
|
||||
* reported separately so nothing downstream has to guess what was thrown away. */
|
||||
#define EL_CAP_GRID_MAX 64
|
||||
|
||||
/* One frame as Map{width, height, grid_w, grid_h, pixels:[Int]}.
|
||||
*
|
||||
* "pixels" is packed R,G,B with NO alpha — three ints per grid cell, row-major
|
||||
* from the TOP-LEFT. (CGBitmapContext lays its buffer out top row first and
|
||||
* CGContextDrawImage does the flip, so row 0 here is the top of the frame, the
|
||||
* same convention periph.swift's grid indexing assumed.) Alpha is dropped
|
||||
* because a camera frame has none worth carrying and it would inflate the list
|
||||
* by a third to say "opaque" six thousand times. */
|
||||
el_val_t camera_capture_rgb(void) {
|
||||
if (!el_cap_authorized(AVMediaTypeVideo)) return (el_val_t)0;
|
||||
|
||||
CGImageRef img = el_cap_grab_frame();
|
||||
if (!img) return (el_val_t)0;
|
||||
|
||||
size_t w = CGImageGetWidth(img);
|
||||
size_t h = CGImageGetHeight(img);
|
||||
if (w == 0 || h == 0) { CGImageRelease(img); return (el_val_t)0; }
|
||||
|
||||
/* Preserve aspect ratio, longest edge capped. */
|
||||
size_t gw = w, gh = h;
|
||||
size_t longest = w > h ? w : h;
|
||||
if (longest > EL_CAP_GRID_MAX) {
|
||||
double s = (double)EL_CAP_GRID_MAX / (double)longest;
|
||||
gw = (size_t)((double)w * s + 0.5);
|
||||
gh = (size_t)((double)h * s + 0.5);
|
||||
if (gw == 0) gw = 1;
|
||||
if (gh == 0) gh = 1;
|
||||
}
|
||||
|
||||
size_t bpr = gw * 4;
|
||||
uint8_t* buf = (uint8_t*)calloc(1, bpr * gh);
|
||||
if (!buf) { CGImageRelease(img); return (el_val_t)0; }
|
||||
|
||||
CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
|
||||
CGContextRef ctx = cs ? CGBitmapContextCreate(buf, gw, gh, 8, bpr, cs,
|
||||
(CGBitmapInfo)kCGImageAlphaPremultipliedLast)
|
||||
: NULL;
|
||||
if (cs) CGColorSpaceRelease(cs);
|
||||
if (!ctx) { free(buf); CGImageRelease(img); return (el_val_t)0; }
|
||||
|
||||
/* Nearest-neighbour. This is a decimation for statistics, not a thumbnail
|
||||
* for a human to look at; smoothing would only cost time and blur the very
|
||||
* region boundaries the grid exists to measure. */
|
||||
CGContextSetInterpolationQuality(ctx, kCGInterpolationNone);
|
||||
CGContextDrawImage(ctx, CGRectMake(0, 0, (CGFloat)gw, (CGFloat)gh), img);
|
||||
CGContextRelease(ctx);
|
||||
CGImageRelease(img);
|
||||
|
||||
el_val_t pixels = el_list_empty();
|
||||
for (size_t y = 0; y < gh; y++) {
|
||||
const uint8_t* row = buf + y * bpr;
|
||||
for (size_t x = 0; x < gw; x++) {
|
||||
const uint8_t* p = row + x * 4; /* RGBA8, premultiplied-last */
|
||||
pixels = el_list_append(pixels, (el_val_t)(int64_t)p[0]);
|
||||
pixels = el_list_append(pixels, (el_val_t)(int64_t)p[1]);
|
||||
pixels = el_list_append(pixels, (el_val_t)(int64_t)p[2]);
|
||||
}
|
||||
}
|
||||
free(buf);
|
||||
|
||||
el_val_t m = el_map_new((el_val_t)0);
|
||||
if (!m) return (el_val_t)0;
|
||||
m = el_map_set(m, EL_STR("width"), (el_val_t)(int64_t)w);
|
||||
m = el_map_set(m, EL_STR("height"), (el_val_t)(int64_t)h);
|
||||
m = el_map_set(m, EL_STR("grid_w"), (el_val_t)(int64_t)gw);
|
||||
m = el_map_set(m, EL_STR("grid_h"), (el_val_t)(int64_t)gh);
|
||||
m = el_map_set(m, EL_STR("pixels"), pixels);
|
||||
return m;
|
||||
}
|
||||
|
||||
/* One frame to disk as JPEG, at FULL resolution — the opposite budget from
|
||||
* camera_capture_rgb, and for the opposite reason. A file is not being walked
|
||||
* element-by-element by an interpreter; it costs one ImageIO call and it is the
|
||||
* artefact a human or a later pass will actually look at. The encoder is
|
||||
* ImageIO's because a JPEG encoder is a codec, and re-implementing one in El
|
||||
* would be a large amount of arithmetic that buys nothing: the point of keeping
|
||||
* work in El is the reasoning, not the entropy coding. */
|
||||
el_val_t camera_capture_jpeg(el_val_t path) {
|
||||
const char* p = EL_CSTR(path);
|
||||
if (!p || !*p) return (el_val_t)0;
|
||||
if (!el_cap_authorized(AVMediaTypeVideo)) return (el_val_t)0;
|
||||
|
||||
CGImageRef img = el_cap_grab_frame();
|
||||
if (!img) return (el_val_t)0;
|
||||
|
||||
int ok = 0;
|
||||
@autoreleasepool {
|
||||
NSString* ns = [NSString stringWithUTF8String:p];
|
||||
NSURL* url = ns ? [NSURL fileURLWithPath:ns] : nil;
|
||||
if (url) {
|
||||
CGImageDestinationRef dst =
|
||||
CGImageDestinationCreateWithURL((__bridge CFURLRef)url, CFSTR("public.jpeg"), 1, NULL);
|
||||
if (dst) {
|
||||
CGImageDestinationAddImage(dst, img, NULL);
|
||||
ok = CGImageDestinationFinalize(dst) ? 1 : 0;
|
||||
CFRelease(dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
CGImageRelease(img);
|
||||
return (el_val_t)ok;
|
||||
}
|
||||
|
||||
#endif /* __APPLE__ */
|
||||
@@ -0,0 +1,89 @@
|
||||
/* el_peripheral_null.c — the no-device build of El's I/O organ.
|
||||
*
|
||||
* Every entry point declared in el_runtime.h's "Peripheral" block, implemented
|
||||
* as an honest refusal. This is what a platform without an El audio/capture
|
||||
* realizer links instead of el_audio_darwin.m + el_capture_darwin.m, so an El
|
||||
* program that speaks or listens still COMPILES AND LINKS everywhere.
|
||||
*
|
||||
* The distinction that matters: these do not pretend. speaker_available() and
|
||||
* mic_available() return 0, and every operation returns its failure sentinel.
|
||||
* A program asking "can I speak here?" gets a truthful no, rather than a
|
||||
* silence it would have to infer something from. Silent success is the failure
|
||||
* mode this whole change exists to eliminate — El spent this entire codebase's
|
||||
* history writing WAV files full of zeros and reporting ok=true, and nobody
|
||||
* caught it because nothing ever said "there is no sound here".
|
||||
*
|
||||
* Compiled INSTEAD OF the Darwin realizers, never alongside them — the symbols
|
||||
* are the same by design, which is the point: the El side never branches on
|
||||
* platform, it branches on speaker_available().
|
||||
*/
|
||||
|
||||
#include "el_runtime.h"
|
||||
|
||||
#if !defined(__APPLE__)
|
||||
|
||||
/* ── Speaker ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
el_val_t speaker_available(void) { return (el_val_t)0; }
|
||||
el_val_t speaker_name(void) { return EL_STR("none"); }
|
||||
|
||||
el_val_t speaker_play_pcm16(el_val_t samples, el_val_t sample_rate) {
|
||||
(void)samples; (void)sample_rate;
|
||||
return (el_val_t)0;
|
||||
}
|
||||
|
||||
el_val_t speaker_play_wav(el_val_t path) {
|
||||
(void)path;
|
||||
return (el_val_t)0;
|
||||
}
|
||||
|
||||
el_val_t speaker_play_pcm16_async(el_val_t samples, el_val_t sample_rate) {
|
||||
(void)samples; (void)sample_rate;
|
||||
return (el_val_t)0;
|
||||
}
|
||||
|
||||
el_val_t speaker_play_wav_async(el_val_t path) {
|
||||
(void)path;
|
||||
return (el_val_t)0;
|
||||
}
|
||||
|
||||
el_val_t speaker_pause(void) { return (el_val_t)0; }
|
||||
el_val_t speaker_resume(void) { return (el_val_t)0; }
|
||||
el_val_t speaker_playing(void) { return (el_val_t)0; }
|
||||
el_val_t speaker_stop(void) { return (el_val_t)0; }
|
||||
el_val_t speaker_played_frames(void) { return (el_val_t)0; }
|
||||
|
||||
/* WAV geometry is pure parsing and would work fine here, but reporting a
|
||||
* duration for audio this build cannot play would invite a caller to sequence
|
||||
* around a silence. Refuse consistently with the rest of the file. */
|
||||
el_val_t wav_frames(el_val_t path) { (void)path; return (el_val_t)0; }
|
||||
el_val_t wav_rate(el_val_t path) { (void)path; return (el_val_t)0; }
|
||||
|
||||
/* ── Microphone ──────────────────────────────────────────────────────────── */
|
||||
|
||||
el_val_t mic_available(void) { return (el_val_t)0; }
|
||||
el_val_t mic_request_access(void) { return (el_val_t)0; }
|
||||
|
||||
/* Empty list, not 0: the contract says capture returns samples, and a caller
|
||||
* iterating the result must find nothing rather than dereference a non-list. */
|
||||
el_val_t mic_capture_pcm16(el_val_t seconds, el_val_t sample_rate) {
|
||||
(void)seconds; (void)sample_rate;
|
||||
return el_list_empty();
|
||||
}
|
||||
|
||||
el_val_t mic_monitor_start(void) { return (el_val_t)0; }
|
||||
el_val_t mic_monitor_rms(void) { return el_from_float(0.0); }
|
||||
el_val_t mic_monitor_stop(void) { return (el_val_t)0; }
|
||||
|
||||
/* ── Camera ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
el_val_t camera_available(void) { return (el_val_t)0; }
|
||||
el_val_t camera_request_access(void) { return (el_val_t)0; }
|
||||
el_val_t camera_capture_rgb(void) { return (el_val_t)0; }
|
||||
|
||||
el_val_t camera_capture_jpeg(el_val_t path) {
|
||||
(void)path;
|
||||
return (el_val_t)0;
|
||||
}
|
||||
|
||||
#endif /* !__APPLE__ */
|
||||
@@ -80,6 +80,92 @@ void println(el_val_t s);
|
||||
void print(el_val_t s);
|
||||
el_val_t readline(void);
|
||||
|
||||
/* stderr counterpart of println (defined in el_seed.c). El could write to
|
||||
* stdout and nowhere else, which is right for a program's RESULT and wrong for
|
||||
* everything about how that result was produced. Disclosure especially has to
|
||||
* leave on a stream the caller can separate from the answer: a program that
|
||||
* announces "I am about to open the microphone" on stdout has corrupted its own
|
||||
* output. Flushed on every call, so a disclosure reaches the terminal BEFORE
|
||||
* the device it describes is touched rather than whenever the buffer drains. */
|
||||
void eprintln(el_val_t s);
|
||||
|
||||
/* ── Peripheral: the speaker, the microphone, the camera ─────────────────────
|
||||
*
|
||||
* El's I/O organ. Implemented per platform in its OWN translation unit —
|
||||
* el_audio_darwin.m / el_capture_darwin.m on Darwin, el_peripheral_null.c
|
||||
* everywhere else — so El code that speaks or listens links on every platform
|
||||
* and merely reports having no device where there isn't one. Declared here and
|
||||
* deliberately NOT implemented in el_runtime.c: acquiring a device must not
|
||||
* mean editing the middle of the language, the same rule the realizer registry
|
||||
* follows for modalities.
|
||||
*
|
||||
* These are the ONLY parts of the organ that are not El. Everything above the
|
||||
* sample buffer — WAV encode/decode, LPC autocorrelation, Levinson-Durbin,
|
||||
* formant extraction, source-filter resynthesis, the compact descriptors, the
|
||||
* converse decision loop — is arithmetic, and arithmetic belongs in El. What
|
||||
* remains here is what El cannot express: handing a buffer to the DAC and
|
||||
* waiting for it to drain, and asking the OS for frames off a capture device.
|
||||
*
|
||||
* Local by construction: none of these entry points has a network path. Samples
|
||||
* and pixels go to and from local hardware and nowhere else. Consent is
|
||||
* enforced ABOVE this layer in El (peripheral/src/organ.el) for the Neuron-level
|
||||
* grant, and BELOW it by the OS for TCC; capture fails closed on either. */
|
||||
|
||||
/* Speaker (efferent). speaker_play_pcm16 BLOCKS until the audio has actually
|
||||
* been played rather than merely queued, so a caller can sequence utterances
|
||||
* without guessing durations and without clipping each tail. */
|
||||
el_val_t speaker_available(void); /* 1 if a real speaker backs this build */
|
||||
el_val_t speaker_name(void); /* backend id, e.g. "coreaudio-audioqueue" */
|
||||
el_val_t speaker_play_pcm16(el_val_t samples, el_val_t sample_rate); /* [Int] 16-bit mono; 1 ok */
|
||||
el_val_t speaker_play_wav(el_val_t path); /* 16-bit mono RIFF/WAVE; 1 ok */
|
||||
|
||||
/* Asynchronous playback — required by converse, which must keep listening while
|
||||
* it speaks and must be able to stop ON THE SPOT mid-buffer. A blocking play
|
||||
* cannot be interrupted, and "finish the current buffer" is not barge-in.
|
||||
* speaker_stop() halts output immediately; speaker_playing() reports whether
|
||||
* the hardware is still going; speaker_played_frames() is how far it actually
|
||||
* got, which is what makes an interrupted utterance resumable at the sample. */
|
||||
el_val_t speaker_play_pcm16_async(el_val_t samples, el_val_t sample_rate);
|
||||
el_val_t speaker_play_wav_async(el_val_t path);
|
||||
el_val_t speaker_pause(void); /* stop AT THIS SAMPLE, keep position */
|
||||
el_val_t speaker_resume(void); /* carry on from exactly there */
|
||||
el_val_t speaker_playing(void);
|
||||
el_val_t speaker_stop(void);
|
||||
el_val_t speaker_played_frames(void);
|
||||
|
||||
/* WAV geometry without playing — wav-info, and the segment duration converse
|
||||
* needs to turn elapsed time into progress. */
|
||||
el_val_t wav_frames(el_val_t path);
|
||||
el_val_t wav_rate(el_val_t path);
|
||||
|
||||
/* Microphone (afferent). Fails CLOSED: returns 0 unless the OS has granted
|
||||
* capture access. mic_capture_pcm16 blocks for `seconds` and returns an [Int]
|
||||
* of 16-bit mono samples at `sample_rate` — the raw stream is handed to El and
|
||||
* never written anywhere by this layer. mic_available() reports device +
|
||||
* permission state without prompting. */
|
||||
el_val_t mic_available(void); /* 1 device present AND OS-authorized */
|
||||
el_val_t mic_request_access(void); /* prompt once; 1 if granted */
|
||||
el_val_t mic_capture_pcm16(el_val_t seconds, el_val_t sample_rate); /* [Int], empty on refusal */
|
||||
|
||||
/* Live monitoring for full-duplex converse. mic_monitor_start enables the OS
|
||||
* voice-processing unit (acoustic echo cancellation) so the microphone does not
|
||||
* hear the speaker — without AEC, Neuron barges in on its own voice and
|
||||
* turn-taking is unusable in a real room. mic_monitor_rms returns the current
|
||||
* short-window RMS as a Float in 0..1. */
|
||||
el_val_t mic_monitor_start(void); /* 1 ok; 2 = started but AEC unavailable */
|
||||
el_val_t mic_monitor_rms(void); /* Float */
|
||||
el_val_t mic_monitor_stop(void);
|
||||
|
||||
/* Camera (afferent). Fails CLOSED like the microphone. camera_capture_rgb
|
||||
* returns a Map with width/height and the frame as an [Int] of packed RGB
|
||||
* bytes, so the descriptor arithmetic can happen in El rather than here.
|
||||
* camera_capture_jpeg writes an encoded frame via ImageIO, which is a codec and
|
||||
* not something El should re-implement. */
|
||||
el_val_t camera_available(void);
|
||||
el_val_t camera_request_access(void);
|
||||
el_val_t camera_capture_rgb(void); /* Map{width,height,pixels:[Int]} or 0 */
|
||||
el_val_t camera_capture_jpeg(el_val_t path); /* 1 ok */
|
||||
|
||||
/* ── String builtins ─────────────────────────────────────────────────────── */
|
||||
|
||||
el_val_t el_str_concat(el_val_t a, el_val_t b);
|
||||
|
||||
+130
-3
@@ -154,9 +154,18 @@ static void seed_request_start(void) {
|
||||
* file still links on its own. */
|
||||
__attribute__((weak)) void el_str_cache_flush(void);
|
||||
|
||||
/* Byte-buffer capacity registry (defined below, next to the string
|
||||
* primitives). The arena frees the pointers it tracked, so any capacity
|
||||
* entry for those addresses must go with them — otherwise a later malloc
|
||||
* reusing the address would inherit a stale width. */
|
||||
static void seed_cap_drop(const char* p);
|
||||
|
||||
static void seed_request_end(void) {
|
||||
_seed_arena_on = 0;
|
||||
for (size_t i = 0; i < _seed_arena.count; i++) free(_seed_arena.ptrs[i]);
|
||||
for (size_t i = 0; i < _seed_arena.count; i++) {
|
||||
seed_cap_drop(_seed_arena.ptrs[i]);
|
||||
free(_seed_arena.ptrs[i]);
|
||||
}
|
||||
_seed_arena.count = 0;
|
||||
if (el_str_cache_flush) el_str_cache_flush(); /* freed pointers may be reused */
|
||||
}
|
||||
@@ -188,6 +197,114 @@ static char* seed_strbuf(size_t n) {
|
||||
|
||||
static el_val_t seed_wrap_str(char* s) { return EL_STR(s); }
|
||||
|
||||
/* ── Byte-buffer capacity registry ────────────────────────────────────────────
|
||||
* A String produced by __str_alloc is a fixed-size BYTE BUFFER, not text. Its
|
||||
* length is the capacity it was asked for; strlen() is meaningless on it,
|
||||
* because the buffer is zero-filled and binary content (PCM audio, RIFF
|
||||
* headers, image rasters) contains NUL bytes by nature.
|
||||
*
|
||||
* Before this registry existed, __str_set_char bounds-checked the write index
|
||||
* against strlen(p). For a freshly __str_alloc'd buffer strlen(p) == 0, so the
|
||||
* check `idx >= len` rejected EVERY index and the function was a total no-op:
|
||||
* every El program that built bytes this way wrote a file of pure zeros and
|
||||
* still saw a success return. That is why El's own-core WAV writer emitted
|
||||
* 55,244 silent bytes with a correct-looking header length and no header.
|
||||
*
|
||||
* The fix cannot be "trust the index", because that removes the bound. It also
|
||||
* cannot be a length header stored behind the pointer, because __str_set_char
|
||||
* accepts any String — including a string literal in .rodata, where reading the
|
||||
* bytes preceding the pointer is undefined and may fault. So capacity is kept
|
||||
* in a side table keyed by the pointer itself: allocation registers, the arena
|
||||
* sweep unregisters, and anything not registered keeps the exact strlen
|
||||
* behaviour it had before. Text semantics are unchanged; byte buffers gain the
|
||||
* bound they always should have had. */
|
||||
|
||||
typedef struct {
|
||||
char* ptr; /* NULL = empty slot, (char*)1 = tombstone */
|
||||
size_t cap;
|
||||
} SeedCapEntry;
|
||||
|
||||
#define SEED_CAP_TOMB ((char*)1)
|
||||
|
||||
static _Thread_local SeedCapEntry* _seed_cap = NULL;
|
||||
static _Thread_local size_t _seed_cap_mask = 0; /* table size - 1 */
|
||||
static _Thread_local size_t _seed_cap_used = 0; /* live + tombstoned */
|
||||
|
||||
static size_t seed_cap_hash(const char* p) {
|
||||
uintptr_t h = (uintptr_t)p >> 4; /* malloc alignment: low bits are dead */
|
||||
h *= (uintptr_t)0x9E3779B97F4A7C15ull;
|
||||
return (size_t)(h >> 32);
|
||||
}
|
||||
|
||||
static void seed_cap_put(char* p, size_t cap);
|
||||
|
||||
static void seed_cap_grow(void) {
|
||||
size_t old_size = _seed_cap_mask ? _seed_cap_mask + 1 : 0;
|
||||
SeedCapEntry* old = _seed_cap;
|
||||
size_t new_size = old_size ? old_size * 2 : 256;
|
||||
SeedCapEntry* fresh = calloc(new_size, sizeof(SeedCapEntry));
|
||||
if (!fresh) return; /* out of memory: keep old table */
|
||||
_seed_cap = fresh;
|
||||
_seed_cap_mask = new_size - 1;
|
||||
_seed_cap_used = 0;
|
||||
for (size_t i = 0; i < old_size; i++) {
|
||||
if (old[i].ptr && old[i].ptr != SEED_CAP_TOMB) seed_cap_put(old[i].ptr, old[i].cap);
|
||||
}
|
||||
free(old);
|
||||
}
|
||||
|
||||
static void seed_cap_put(char* p, size_t cap) {
|
||||
if (!p) return;
|
||||
if (!_seed_cap || (_seed_cap_used + 1) * 4 >= (_seed_cap_mask + 1) * 3) {
|
||||
seed_cap_grow();
|
||||
if (!_seed_cap) return;
|
||||
}
|
||||
size_t i = seed_cap_hash(p) & _seed_cap_mask;
|
||||
size_t first_free = (size_t)-1;
|
||||
for (;;) {
|
||||
char* e = _seed_cap[i].ptr;
|
||||
if (e == p) { _seed_cap[i].cap = cap; return; } /* address reused */
|
||||
if (e == SEED_CAP_TOMB && first_free == (size_t)-1) first_free = i;
|
||||
if (!e) {
|
||||
if (first_free != (size_t)-1) i = first_free; else _seed_cap_used++;
|
||||
_seed_cap[i].ptr = p;
|
||||
_seed_cap[i].cap = cap;
|
||||
return;
|
||||
}
|
||||
i = (i + 1) & _seed_cap_mask;
|
||||
}
|
||||
}
|
||||
|
||||
/* Capacity of a registered byte buffer, or -1 when the pointer is not one. */
|
||||
static int64_t seed_cap_get(const char* p) {
|
||||
if (!p || !_seed_cap) return -1;
|
||||
size_t i = seed_cap_hash(p) & _seed_cap_mask;
|
||||
for (;;) {
|
||||
char* e = _seed_cap[i].ptr;
|
||||
if (!e) return -1;
|
||||
if (e == (char*)p) return (int64_t)_seed_cap[i].cap;
|
||||
i = (i + 1) & _seed_cap_mask;
|
||||
}
|
||||
}
|
||||
|
||||
static void seed_cap_drop(const char* p) {
|
||||
if (!p || !_seed_cap) return;
|
||||
size_t i = seed_cap_hash(p) & _seed_cap_mask;
|
||||
for (;;) {
|
||||
char* e = _seed_cap[i].ptr;
|
||||
if (!e) return;
|
||||
if (e == (char*)p) { _seed_cap[i].ptr = SEED_CAP_TOMB; return; }
|
||||
i = (i + 1) & _seed_cap_mask;
|
||||
}
|
||||
}
|
||||
|
||||
/* Effective addressable length of a String: its buffer capacity when it is a
|
||||
* byte buffer, otherwise strlen. */
|
||||
static int64_t seed_addressable_len(const char* p) {
|
||||
int64_t cap = seed_cap_get(p);
|
||||
return cap >= 0 ? cap : (int64_t)strlen(p);
|
||||
}
|
||||
|
||||
/* ── String primitives ───────────────────────────────────────────────────── */
|
||||
|
||||
el_val_t __str_len(el_val_t s) {
|
||||
@@ -199,7 +316,7 @@ el_val_t __str_len(el_val_t s) {
|
||||
el_val_t __str_char_at(el_val_t s, el_val_t i) {
|
||||
const char* p = EL_CSTR(s);
|
||||
if (!p) return 0;
|
||||
int64_t len = (int64_t)strlen(p);
|
||||
int64_t len = seed_addressable_len(p); /* capacity for byte buffers */
|
||||
int64_t idx = (int64_t)i;
|
||||
if (idx < 0 || idx >= len) return 0;
|
||||
return (el_val_t)(unsigned char)p[idx];
|
||||
@@ -210,13 +327,14 @@ el_val_t __str_alloc(el_val_t n) {
|
||||
if (sz < 0) sz = 0;
|
||||
char* buf = seed_strbuf((size_t)sz);
|
||||
memset(buf, 0, (size_t)sz + 1);
|
||||
seed_cap_put(buf, (size_t)sz); /* this is a byte buffer of width sz */
|
||||
return seed_wrap_str(buf);
|
||||
}
|
||||
|
||||
el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c) {
|
||||
char* p = (char*)(uintptr_t)s;
|
||||
if (!p) return s;
|
||||
int64_t len = (int64_t)strlen(p);
|
||||
int64_t len = seed_addressable_len(p); /* capacity for byte buffers */
|
||||
int64_t idx = (int64_t)i;
|
||||
if (idx < 0 || idx >= len) return s;
|
||||
p[idx] = (char)(unsigned char)(int64_t)c;
|
||||
@@ -406,6 +524,15 @@ el_val_t __fs_mkdir(el_val_t path) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* stderr counterpart of println. Flushed immediately: a disclosure line is only
|
||||
* worth anything if it lands before the thing it discloses happens. */
|
||||
void eprintln(el_val_t s) {
|
||||
const char* p = EL_CSTR(s);
|
||||
fputs(p ? p : "", stderr);
|
||||
fputc('\n', stderr);
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
el_val_t __fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t n) {
|
||||
const char* p = EL_CSTR(path);
|
||||
const char* b = EL_CSTR(bytes);
|
||||
|
||||
Reference in New Issue
Block a user