From 3af3eca2bfd81fa5785441834214009ef8b38dc3 Mon Sep 17 00:00:00 2001 From: Teodor Date: Tue, 25 Aug 2026 21:29:14 +0800 Subject: [PATCH 1/6] feat(audio): select the host audio device by name --- docs/SPEECH-REMOTE-AUDIO.md | 49 +++ emu/MacOSX/audio-sdl3.c | 402 ++++++++++++++++--- emu/MacOSX/audio.c | 531 +++++++++++++++++++++++++ emu/MacOSX/mkfile | 2 + emu/MacOSX/mkfile-g | 2 + emu/port/audio-tbls.c | 1 + emu/port/audio.h | 34 +- emu/port/devaudio.c | 102 +++++ tests/audioctl_readback_test.b | 238 +++++++++++ tests/host/audio_device_select_test.sh | 77 ++++ tests/host/audio_macos_test.sh | 90 +++++ tests/mkfile | 1 + tools/dis-manifest.txt | 1 + 13 files changed, 1470 insertions(+), 60 deletions(-) create mode 100644 emu/MacOSX/audio.c create mode 100644 tests/audioctl_readback_test.b create mode 100755 tests/host/audio_device_select_test.sh create mode 100755 tests/host/audio_macos_test.sh diff --git a/docs/SPEECH-REMOTE-AUDIO.md b/docs/SPEECH-REMOTE-AUDIO.md index 4eab8b98c..38efb94c3 100644 --- a/docs/SPEECH-REMOTE-AUDIO.md +++ b/docs/SPEECH-REMOTE-AUDIO.md @@ -75,6 +75,55 @@ bolted on as a special case. --- +### Choosing the local input device: `#A/audiodev` + +The emulator opened whatever the host called the default input device. That +default is not always a microphone: a virtual device installed by another +application can hold it, and such a device produces no samples unless that +application is running. Capture then opens, every sample is zero, and no layer +reports a fault. A silent device and a silent room look identical, and finding +out which it was meant leaving InferNode for a host tool. + +`#A/audiodev` makes both the choice and the verdict visible. Read it to +enumerate the devices the host offers and see which is selected: + +``` +; bind -a '#A' /dev +; cat /dev/audiodev +in selected default +in device 'Built-in Microphone' +in device 'USB Audio Device' +out selected default +out device 'Built-in Output' +capture idle +``` + +Write `in ` or `out ` to select one. Names are quoted in the +readback because they contain spaces, and either form is accepted on write, so +a line from the read can be written straight back. `in default` returns to +following the host default. An unknown name is refused rather than ignored. +Selection takes effect at once: a stream that is already open is reopened on +the new device. + +```sh +echo 'in Built-in Microphone' > /dev/audiodev +``` + +The last line is the diagnostic. `capture active` means the device produced at +least one non-zero sample. `capture silent` means it delivered data and all of +it was zero. `capture idle` means it delivered nothing. It describes the most +recent capture rather than only a live one, so it can be read after a recording +finishes. A capture that stays silent for two seconds also prints a one-time +warning to stderr. + +`capture silent` has more than one cause — a virtual device with nothing behind +it, or microphone authorization the host has not granted. The file does not say +which, but it distinguishes all of them from a quiet room, which is what the +caller could not do before. + +Backends without device enumeration report `unsupported`, and the emulator +follows the host default as it did before. + ## Current GUI / Veltro Limitations (Future Work) The final step — mounting the remote speech service — is **already supported** by the diff --git a/emu/MacOSX/audio-sdl3.c b/emu/MacOSX/audio-sdl3.c index 9d87351a4..0273d64b3 100644 --- a/emu/MacOSX/audio-sdl3.c +++ b/emu/MacOSX/audio-sdl3.c @@ -67,6 +67,47 @@ #include "audio-tbls.c" +/* + * Per-stream queue caps in milliseconds (INFR-194). Shared + * by the SDL3 backend and the headless stub so audioctl can report + * what was written even when there is no queue to cap. + * + * Defaults are 0 (no cap) so non-voice workloads — audiotone, music + * playback, anything that bulk-writes ahead of the device — keep + * smooth, unbounded queueing. Voice opts in by writing to the ctl: + * + * echo 'play_buffer_ms 100' > /dev/audioctl + * echo 'rec_buffer_ms 100' > /dev/audioctl + * + * Drop policy on the SDL3 path is HEAD-drop. Headless stores the + * verbs only. + */ +static int play_buffer_ms = 0; +static int rec_buffer_ms = 0; + +static void +audio_get_buffer_ms(int *play, int *rec) +{ + *play = play_buffer_ms; + *rec = rec_buffer_ms; +} + +static int +audio_parse_buffer_ms(char *s, long n) +{ + if(n > 15 && memcmp(s, "play_buffer_ms ", 15) == 0){ + play_buffer_ms = atoi(s + 15); + return 1; + } + if(n > 14 && memcmp(s, "rec_buffer_ms ", 14) == 0){ + rec_buffer_ms = atoi(s + 14); + return 1; + } + return 0; +} + + + #ifdef GUI_SDL3 /* @@ -102,29 +143,25 @@ static QLock inlock; static QLock outlock; static int in_refcnt; /* opens still holding /dev/audio for read */ static int out_refcnt; /* opens still holding /dev/audio for write */ + +static void +select_audio_driver(void) +{ +#ifdef __ANDROID__ + /* Android API 28+ ships AAudio and the APK links SDL3's AAudio backend. */ + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "aaudio"); +#else + /* macOS and iOS use SDL3's CoreAudio backend. */ + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "coreaudio"); +#endif +} static Audio_t av; /* current format (in.rate / chan / bits, out.*) */ /* - * Per-stream queue caps in milliseconds (INFR-194). Without these - * SDL_AudioStream queues are unbounded; on a sustained 9P voice flow - * the macOS playback side accumulates whatever the network feeds it - * and the audio drifts minutes behind the speaker. - * - * Defaults are 0 (no cap) so non-voice workloads — audiotone, music - * playback, anything that bulk-writes ahead of the device — keep - * smooth, unbounded queueing. Voice opts in by writing to the ctl: - * - * echo 'play_buffer_ms 100' > /dev/audioctl - * echo 'rec_buffer_ms 100' > /dev/audioctl - * - * voice/listen and voice/dial both set these as part of their setup, - * so end users don't have to think about it. - * - * Drop policy is HEAD-drop (clear the stale catch-up backlog, keep - * the freshest data). For voice that's always right. + * SDL3 applies play_buffer_ms / rec_buffer_ms as queue depth caps + * (INFR-194). See the shared statics above audio_get_buffer_ms. */ -static int play_buffer_ms = 0; -static int rec_buffer_ms = 0; + /* bytes-per-second for a given Audio_d — used to translate the * per-direction buffer cap from ms to bytes against the live format. */ @@ -146,11 +183,40 @@ sdlfmt(ulong bits) return SDL_AUDIO_S16LE; } +/* Retry budget for an audio subsystem that comes up with an empty device + * list — see ensure_sdl_audio. */ +#define AUDIO_INIT_TRIES 20 +#define AUDIO_INIT_DELAY_MS 50 + +static int +audio_devices_present(void) +{ + int nrec = 0, nplay = 0; + SDL_AudioDeviceID *rec, *play; + + rec = SDL_GetAudioRecordingDevices(&nrec); + play = SDL_GetAudioPlaybackDevices(&nplay); + if(rec != nil) + SDL_free(rec); + if(play != nil) + SDL_free(play); + return nrec > 0 || nplay > 0; +} + static int ensure_sdl_audio(void) { - if(sdl_audio_inited) - return 1; + int i; + + /* An earlier caller may have left the subsystem up with an empty + * device list. That state never recovers on its own, so re-init + * rather than trusting the flag. */ + if(sdl_audio_inited) { + if(audio_devices_present()) + return 1; + SDL_QuitSubSystem(SDL_INIT_AUDIO); + sdl_audio_inited = 0; + } /* Configure AVAudioSession (or platform equivalent) before SDL * touches CoreAudio — no-op on macOS/Linux desktop, real impl on * iOS. INFR-186. */ @@ -165,25 +231,107 @@ ensure_sdl_audio(void) * a subsystem has already been brought up; it's a no-op past the * first invocation for the same flags. * - * Force the CoreAudio driver explicitly via the hint — without - * this, SDL3 sometimes silently falls back to a dummy driver - * when run from a non-GUI process on macOS, which then surfaces - * as "No default audio device available" instead of the more - * obvious "no audio backend available." + * Select the platform's real audio driver explicitly — without this, + * SDL3 can silently fall back to a dummy driver in a non-GUI process. + * Android uses AAudio; Apple targets use CoreAudio. */ - SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "coreaudio"); - if(!SDL_Init(SDL_INIT_AUDIO)) { - fprint(2, "audio-sdl3: SDL_Init(SDL_INIT_AUDIO) failed: %s\n", - SDL_GetError()); - return 0; + /* + * SDL_Init can report success while the driver still enumerates no + * devices at all. An audio subsystem in that state is useless: every + * open fails with "No default audio device available", and the + * failure is indistinguishable from a machine with no sound card. + * Bring the subsystem back up until the devices appear, bounded so a + * genuinely silent host (CI, headless runner) still settles quickly + * and fails the open the way it always has. + */ + for(i = 0;; i++) { + select_audio_driver(); + if(!SDL_Init(SDL_INIT_AUDIO)) { + fprint(2, "audio-sdl3: SDL_Init(SDL_INIT_AUDIO) failed: %s\n", + SDL_GetError()); + return 0; + } + if(audio_devices_present() || i >= AUDIO_INIT_TRIES) + break; + SDL_QuitSubSystem(SDL_INIT_AUDIO); + SDL_Delay(AUDIO_INIT_DELAY_MS); } sdl_audio_inited = 1; return 1; } +/* + * Host device selection (see audio.h). Empty means "follow the system + * default", which is what the emulator did unconditionally before. + */ +static char in_devname[128]; +static char out_devname[128]; + +/* Capture-silence accounting. A device that opens and then delivers + * nothing but zeroes is the failure this selection exists to diagnose: + * a virtual input from an app that is not running, an OS-muted device, + * or missing microphone authorization all look identical to a working + * microphone in a quiet room until you count the samples. */ +static vlong cap_bytes; +static vlong cap_nonzero; +static int cap_warned; +static Uint64 cap_opened_ms; + +/* How long a capture may stay all-zero before it is worth saying so. + * Measured in wall time rather than bytes: the devices that fail this + * way often deliver almost no data either, so a byte threshold can take + * minutes to reach — or never arrive at all. */ +#define CAP_SILENT_MS 2000 + +static SDL_AudioDeviceID +find_device(int isin, const char *name) +{ + SDL_AudioDeviceID *ids, id = 0; + const char *dn; + int i, n = 0; + + ids = isin ? SDL_GetAudioRecordingDevices(&n) + : SDL_GetAudioPlaybackDevices(&n); + if(ids == nil) + return 0; + for(i = 0; i < n; i++) { + dn = SDL_GetAudioDeviceName(ids[i]); + if(dn != nil && strcmp(dn, name) == 0) { + id = ids[i]; + break; + } + } + SDL_free(ids); + return id; +} + +/* + * Resolve the configured name to a live SDL id at open time. A name that + * matches nothing falls back to the system default rather than failing + * the open: devices are unplugged, and a warning plus the default beats + * a voice stack that refuses to start. + */ +static SDL_AudioDeviceID +selected_device(int isin) +{ + SDL_AudioDeviceID id; + char *name = isin ? in_devname : out_devname; + + if(name[0] != 0) { + if((id = find_device(isin, name)) != 0) + return id; + fprint(2, "audio-sdl3: no %s device named \"%s\"; " + "using the system default\n", + isin ? "input" : "output", name); + } + return isin ? SDL_AUDIO_DEVICE_DEFAULT_RECORDING + : SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; +} + static SDL_AudioStream * -open_stream(SDL_AudioDeviceID dev, Audio_d *fmt) +open_stream(int isin, Audio_d *fmt) { + SDL_AudioDeviceID dev = selected_device(isin); SDL_AudioSpec spec; SDL_AudioStream *s; @@ -214,7 +362,7 @@ open_stream(SDL_AudioDeviceID dev, Audio_d *fmt) */ SDL_QuitSubSystem(SDL_INIT_AUDIO); sdl_audio_inited = 0; - SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "coreaudio"); + select_audio_driver(); if(SDL_Init(SDL_INIT_AUDIO)) { sdl_audio_inited = 1; s = SDL_OpenAudioDeviceStream(dev, &spec, NULL, NULL); @@ -222,9 +370,9 @@ open_stream(SDL_AudioDeviceID dev, Audio_d *fmt) } } if(s == NULL) { - fprint(2, "audio-sdl3: SDL_OpenAudioDeviceStream(%s) failed: %s\n", - dev == SDL_AUDIO_DEVICE_DEFAULT_RECORDING ? "rec" : "play", - SDL_GetError()); + fprint(2, "audio-sdl3: SDL_OpenAudioDeviceStream(%s) failed: %s (devices present: %s)\n", + isin ? "rec" : "play", + SDL_GetError(), audio_devices_present() ? "yes" : "no"); return NULL; } /* Streams are bound paused — resume so data starts flowing. */ @@ -232,10 +380,129 @@ open_stream(SDL_AudioDeviceID dev, Audio_d *fmt) return s; } +/* + * #A/audiodev readback. Names are quoted because almost every real one + * contains spaces, and quoting makes a line from this read valid input to + * the same file. The trailing capture line reports whether the input + * device has actually produced a non-zero sample, which is the one thing + * that distinguishes a silenced or virtual microphone from a quiet room. + */ +static int +sdl_devs_list(char *buf, char *e) +{ + SDL_AudioDeviceID *ids; + const char *dn, *sel; + char *p = buf; + int dir, i, n; + + if(!ensure_sdl_audio()) + return snprint(buf, e - buf, "unavailable\n"); + + for(dir = 1; dir >= 0; dir--) { + sel = dir ? in_devname : out_devname; + if(sel[0] != 0) + p = seprint(p, e, "%s selected '%s'\n", + dir ? "in" : "out", sel); + else + p = seprint(p, e, "%s selected default\n", + dir ? "in" : "out"); + ids = dir ? SDL_GetAudioRecordingDevices(&n) + : SDL_GetAudioPlaybackDevices(&n); + if(ids == nil) + continue; + for(i = 0; i < n; i++) { + dn = SDL_GetAudioDeviceName(ids[i]); + if(dn != nil) + p = seprint(p, e, "%s device '%s'\n", + dir ? "in" : "out", dn); + } + SDL_free(ids); + } + + /* Reported for the most recent capture, not only a live one: the + * useful question is "did that recording hear anything", and it is + * usually asked after the stream has been closed again. */ + if(cap_bytes == 0) + p = seprint(p, e, "capture idle\n"); + else if(cap_nonzero > 0) + p = seprint(p, e, "capture active\n"); + else + p = seprint(p, e, "capture silent\n"); + + return p - buf; +} + +/* + * Take effect immediately: a stream already open on the old device is + * reopened on the new one, so an operator can fix a wrong input device + * without restarting the voice stack. + */ +static int +sdl_devs_select(int isin, char *name) +{ + /* Selection can arrive before anything has opened /dev/audio, and + * SDL enumerates nothing until the subsystem is up — without this + * every name looks unknown. */ + if(name[0] != 0) { + if(!ensure_sdl_audio()) + return -1; + if(find_device(isin, name) == 0) + return -1; + } + + if(isin) { + qlock(&inlock); + snprint(in_devname, sizeof in_devname, "%s", name); + if(in_stream != NULL) { + SDL_DestroyAudioStream(in_stream); + cap_bytes = cap_nonzero = 0; + cap_warned = 0; + cap_opened_ms = SDL_GetTicks(); + in_stream = open_stream(1, &av.in); + } + qunlock(&inlock); + } else { + qlock(&outlock); + snprint(out_devname, sizeof out_devname, "%s", name); + if(out_stream != NULL) { + SDL_DestroyAudioStream(out_stream); + out_stream = open_stream(0, &av.out); + } + qunlock(&outlock); + } + return 0; +} + +static Audiodevops sdl_devops = { sdl_devs_list, sdl_devs_select }; + +/* + * Starting device names from the host environment. A launcher that wants + * a particular device — a test rig pointing capture at a virtual input, + * a kiosk pinned to a USB headset — sets these before exec rather than + * writing to #A/audiodev after boot, which would be too late: the voice + * stack opens capture during startup. An unknown name is not validated + * here (SDL is deliberately not up yet); selected_device warns and falls + * back to the system default at open time. + */ +static void +devnames_from_env(void) +{ + char *s; + + if((s = getenv("INFERNODE_AUDIO_IN")) != nil && *s != 0) + snprint(in_devname, sizeof in_devname, "%s", s); + if((s = getenv("INFERNODE_AUDIO_OUT")) != nil && *s != 0) + snprint(out_devname, sizeof out_devname, "%s", s); +} + void audio_file_init(void) { audio_info_init(&av); + audio_devops_register(&sdl_devops); + audio_bufcaps_register(audio_get_buffer_ms); + + devnames_from_env(); /* SDL_InitSubSystem is deferred until first open so a headless * build with no audio HW (e.g. CI runners) doesn't pay startup * cost or trigger a TCC prompt it can't satisfy. */ @@ -261,8 +528,10 @@ audio_file_open(Chan *c, int omode) if(omode == OREAD || omode == ORDWR) { qlock(&inlock); if(in_stream == NULL) { - in_stream = open_stream(SDL_AUDIO_DEVICE_DEFAULT_RECORDING, - &av.in); + cap_bytes = cap_nonzero = 0; + cap_warned = 0; + cap_opened_ms = SDL_GetTicks(); + in_stream = open_stream(1, &av.in); if(in_stream == NULL) { qunlock(&inlock); error("audio in unavailable"); @@ -274,8 +543,7 @@ audio_file_open(Chan *c, int omode) if(omode == OWRITE || omode == ORDWR) { qlock(&outlock); if(out_stream == NULL) { - out_stream = open_stream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, - &av.out); + out_stream = open_stream(0, &av.out); if(out_stream == NULL) { /* clean up the input we just acquired */ if(omode == ORDWR) { @@ -343,6 +611,28 @@ audio_file_read(Chan *c, void *va, long n, vlong off) } SDL_Delay(5); /* ~220 frames @ 44.1k stereo 16-bit */ } + + /* Count towards the silence diagnostic until the device has proved + * itself once; after that the scan stops costing anything. */ + if(cap_nonzero == 0) { + uchar *b = va; + long i; + + for(i = 0; i < got; i++) + if(b[i] != 0) { + cap_nonzero += got; + break; + } + cap_bytes += got; + if(cap_nonzero == 0 && !cap_warned && + SDL_GetTicks() - cap_opened_ms > CAP_SILENT_MS) { + fprint(2, "audio-sdl3: the capture device has produced " + "nothing but silence for 2s — check the input " + "device in #A/audiodev and microphone " + "authorization\n"); + cap_warned = 1; + } + } return got; } @@ -387,21 +677,11 @@ audio_ctl_write(Chan *c, void *va, long n, vlong off) /* * Buffer-cap verbs (INFR-194). Parsed and stripped here before * audioparse sees the rest of the line so audioparse doesn't - * have to learn about non-format verbs. Each verb is a key + int. - * "play_buffer_ms N" — cap playback queue depth, 0 = unbounded - * "rec_buffer_ms N" — cap capture queue depth, 0 = unbounded + * have to learn about non-format verbs. */ - { - char *s = (char*)va; - if(n > 16 && memcmp(s, "play_buffer_ms ", 15) == 0) { - play_buffer_ms = atoi(s + 15); - return n; - } - if(n > 15 && memcmp(s, "rec_buffer_ms ", 14) == 0) { - rec_buffer_ms = atoi(s + 14); - return n; - } - } + if(audio_parse_buffer_ms((char*)va, n)) + return n; + /* Parse the verb into a scratch struct first so a malformed line * leaves the live av untouched. audioparse mutates only the @@ -419,8 +699,7 @@ audio_ctl_write(Chan *c, void *va, long n, vlong off) tmp.in.bits != av.in.bits)) { qlock(&inlock); SDL_DestroyAudioStream(in_stream); - in_stream = open_stream(SDL_AUDIO_DEVICE_DEFAULT_RECORDING, - &tmp.in); + in_stream = open_stream(1, &tmp.in); qunlock(&inlock); } if(out_stream && @@ -428,8 +707,7 @@ audio_ctl_write(Chan *c, void *va, long n, vlong off) tmp.out.bits != av.out.bits)) { qlock(&outlock); SDL_DestroyAudioStream(out_stream); - out_stream = open_stream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, - &tmp.out); + out_stream = open_stream(0, &tmp.out); qunlock(&outlock); } av = tmp; @@ -504,8 +782,10 @@ void audio_file_init(void) { audio_info_init(&av); + audio_bufcaps_register(audio_get_buffer_ms); } + void audio_ctl_init(void) { @@ -540,10 +820,14 @@ audio_file_write(Chan *c, void *va, long n, vlong off) long audio_ctl_write(Chan *c, void *va, long n, vlong off) { - USED(c); USED(va); USED(off); + USED(c); USED(off); + if(audio_parse_buffer_ms((char*)va, n)) + return n; + USED(va); return n; } + void audio_file_close(Chan *c) { diff --git a/emu/MacOSX/audio.c b/emu/MacOSX/audio.c new file mode 100644 index 000000000..c39277b85 --- /dev/null +++ b/emu/MacOSX/audio.c @@ -0,0 +1,531 @@ +#include "dat.h" +#include "fns.h" +#include "error.h" +#include "audio.h" +#include +#include +#include + +#define Audio_Mic_Val 1 +#define Audio_Linein_Val 2 + +#define Audio_Speaker_Val 1 +#define Audio_Headphone_Val 2 +#define Audio_Lineout_Val 3 + +#define Audio_Pcm_Val 1 +#define Audio_Ulaw_Val 2 +#define Audio_Alaw_Val 3 + +#include "audio-tbls.c" + +#define Nqueuebuf 3 +#define Defbufsz 8192 +#define Defringsz 262144 + +#define min(a,b) ((a) < (b) ? (a) : (b)) + +typedef struct Ring Ring; +struct Ring { + uchar *data; + int size; + int r; + int w; + int fill; + pthread_mutex_t lk; + pthread_cond_t canread; + pthread_cond_t canwrite; +}; + +static Audio_t av; +static QLock inlock; +static QLock outlock; + +static int inopen; +static int outopen; +static int instarted; +static int outstarted; +static Ring inring; +static Ring outring; +static AudioQueueRef inq; +static AudioQueueRef outq; +static AudioQueueBufferRef inbuf[Nqueuebuf]; +static AudioQueueBufferRef outbuf[Nqueuebuf]; +static AudioStreamBasicDescription infmt; +static AudioStreamBasicDescription outfmt; + +static void closinput(void); +static void closoutput(void); +static void startinput(void); +static void startoutput(void); + +Audio_t* +getaudiodev(void) +{ + return &av; +} + +void +audio_file_init(void) +{ + audio_info_init(&av); +} + +static void +coreerror(char *what, OSStatus s) +{ + char buf[ERRMAX]; + + snprint(buf, sizeof(buf), "%s: %ld", what, (long)s); + error(buf); +} + +static void +ringinit(Ring *r, int n) +{ + if(r->data != nil && r->size == n) + return; + if(r->data != nil) + free(r->data); + r->data = malloc(n); + if(r->data == nil) + error(Enomem); + r->size = n; + r->r = 0; + r->w = 0; + r->fill = 0; + pthread_mutex_init(&r->lk, nil); + pthread_cond_init(&r->canread, nil); + pthread_cond_init(&r->canwrite, nil); +} + +static void +ringreset(Ring *r) +{ + pthread_mutex_lock(&r->lk); + r->r = 0; + r->w = 0; + r->fill = 0; + pthread_cond_broadcast(&r->canread); + pthread_cond_broadcast(&r->canwrite); + pthread_mutex_unlock(&r->lk); +} + +static void +ringfree(Ring *r) +{ + uchar *p; + + if(r->data == nil) + return; + pthread_mutex_lock(&r->lk); + p = r->data; + r->data = nil; + r->size = 0; + r->r = 0; + r->w = 0; + r->fill = 0; + pthread_cond_broadcast(&r->canread); + pthread_cond_broadcast(&r->canwrite); + pthread_mutex_unlock(&r->lk); + free(p); + pthread_cond_destroy(&r->canread); + pthread_cond_destroy(&r->canwrite); + pthread_mutex_destroy(&r->lk); +} + +static int +ringreadnb(Ring *r, uchar *p, int n) +{ + int m, got; + + got = 0; + pthread_mutex_lock(&r->lk); + while(got < n && r->fill > 0){ + m = min(n - got, r->fill); + m = min(m, r->size - r->r); + memcpy(p + got, r->data + r->r, m); + r->r = (r->r + m) % r->size; + r->fill -= m; + got += m; + } + if(got > 0) + pthread_cond_broadcast(&r->canwrite); + pthread_mutex_unlock(&r->lk); + return got; +} + +static int +ringwritenb(Ring *r, uchar *p, int n) +{ + int m, put; + + put = 0; + pthread_mutex_lock(&r->lk); + while(put < n && r->fill < r->size){ + m = min(n - put, r->size - r->fill); + m = min(m, r->size - r->w); + memcpy(r->data + r->w, p + put, m); + r->w = (r->w + m) % r->size; + r->fill += m; + put += m; + } + if(put > 0) + pthread_cond_broadcast(&r->canread); + pthread_mutex_unlock(&r->lk); + return put; +} + +static void +ringwrite(Ring *r, uchar *p, int n) +{ + int m; + + pthread_mutex_lock(&r->lk); + while(n > 0){ + while(r->fill == r->size) + pthread_cond_wait(&r->canwrite, &r->lk); + m = min(n, r->size - r->fill); + m = min(m, r->size - r->w); + memcpy(r->data + r->w, p, m); + r->w = (r->w + m) % r->size; + r->fill += m; + p += m; + n -= m; + pthread_cond_broadcast(&r->canread); + } + pthread_mutex_unlock(&r->lk); +} + +static void +ringread(Ring *r, uchar *p, int n) +{ + int m; + + pthread_mutex_lock(&r->lk); + while(n > 0){ + while(r->fill == 0) + pthread_cond_wait(&r->canread, &r->lk); + m = min(n, r->fill); + m = min(m, r->size - r->r); + memcpy(p, r->data + r->r, m); + r->r = (r->r + m) % r->size; + r->fill -= m; + p += m; + n -= m; + pthread_cond_broadcast(&r->canwrite); + } + pthread_mutex_unlock(&r->lk); +} + +static void +setformat(Audio_d *d, AudioStreamBasicDescription *fmt) +{ + if(d->enc != Audio_Pcm_Val) + error("unsupported macOS audio encoding"); + if(d->bits != 8 && d->bits != 16) + error("unsupported macOS audio sample size"); + if(d->chan != 1 && d->chan != 2) + error("unsupported macOS audio channel count"); + memset(fmt, 0, sizeof(*fmt)); + fmt->mSampleRate = d->rate; + fmt->mFormatID = kAudioFormatLinearPCM; + fmt->mFormatFlags = kLinearPCMFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked; + fmt->mBitsPerChannel = d->bits; + fmt->mChannelsPerFrame = d->chan; + fmt->mFramesPerPacket = 1; + fmt->mBytesPerFrame = (d->bits / Bits_Per_Byte) * d->chan; + fmt->mBytesPerPacket = fmt->mBytesPerFrame; +} + +static int +ringsz(Audio_d *d) +{ + int n; + + n = d->buf * Audio_Max_Buf / Audio_Max_Val; + if(n < Defbufsz) + n = Defbufsz; + if(n < Defringsz) + n = Defringsz; + return n; +} + +static void +outcallback(void *arg, AudioQueueRef q, AudioQueueBufferRef b) +{ + int n; + + USED(arg); + n = ringreadnb(&outring, b->mAudioData, b->mAudioDataBytesCapacity); + if(n < (int)b->mAudioDataBytesCapacity) + memset((uchar*)b->mAudioData + n, 0, b->mAudioDataBytesCapacity - n); + b->mAudioDataByteSize = b->mAudioDataBytesCapacity; + AudioQueueEnqueueBuffer(q, b, 0, nil); +} + +static void +incallback(void *arg, AudioQueueRef q, AudioQueueBufferRef b, + const AudioTimeStamp *start, UInt32 packets, const AudioStreamPacketDescription *desc) +{ + USED(arg); + USED(start); + USED(packets); + USED(desc); + if(b->mAudioDataByteSize > 0) + ringwritenb(&inring, b->mAudioData, b->mAudioDataByteSize); + AudioQueueEnqueueBuffer(q, b, 0, nil); +} + +static void +startoutput(void) +{ + OSStatus s; + int i; + + if(outstarted) + return; + setformat(&av.out, &outfmt); + ringinit(&outring, ringsz(&av.out)); + ringreset(&outring); + s = AudioQueueNewOutput(&outfmt, outcallback, nil, nil, nil, 0, &outq); + if(s != noErr) + coreerror("cannot open CoreAudio output", s); + for(i = 0; i < Nqueuebuf; i++){ + s = AudioQueueAllocateBuffer(outq, Defbufsz, &outbuf[i]); + if(s != noErr) + coreerror("cannot allocate CoreAudio output buffer", s); + memset(outbuf[i]->mAudioData, 0, Defbufsz); + outbuf[i]->mAudioDataByteSize = Defbufsz; + s = AudioQueueEnqueueBuffer(outq, outbuf[i], 0, nil); + if(s != noErr) + coreerror("cannot enqueue CoreAudio output buffer", s); + } + s = AudioQueueStart(outq, nil); + if(s != noErr) + coreerror("cannot start CoreAudio output", s); + outstarted = 1; +} + +static void +startinput(void) +{ + OSStatus s; + int i; + + if(instarted) + return; + setformat(&av.in, &infmt); + ringinit(&inring, ringsz(&av.in)); + ringreset(&inring); + s = AudioQueueNewInput(&infmt, incallback, nil, nil, nil, 0, &inq); + if(s != noErr) + coreerror("cannot open CoreAudio input", s); + for(i = 0; i < Nqueuebuf; i++){ + s = AudioQueueAllocateBuffer(inq, Defbufsz, &inbuf[i]); + if(s != noErr) + coreerror("cannot allocate CoreAudio input buffer", s); + s = AudioQueueEnqueueBuffer(inq, inbuf[i], 0, nil); + if(s != noErr) + coreerror("cannot enqueue CoreAudio input buffer", s); + } + s = AudioQueueStart(inq, nil); + if(s != noErr) + coreerror("cannot start CoreAudio input", s); + instarted = 1; +} + +static void +closoutput(void) +{ + if(outq != nil){ + AudioQueueStop(outq, true); + AudioQueueDispose(outq, true); + outq = nil; + } + outstarted = 0; + ringfree(&outring); +} + +static void +closinput(void) +{ + if(inq != nil){ + AudioQueueStop(inq, true); + AudioQueueDispose(inq, true); + inq = nil; + } + instarted = 0; + ringfree(&inring); +} + +void +audio_file_open(Chan *c, int omode) +{ + USED(c); + switch(omode){ + case OREAD: + qlock(&inlock); + if(waserror()){ + qunlock(&inlock); + nexterror(); + } + if(inopen) + error(Einuse); + inopen = 1; + poperror(); + qunlock(&inlock); + break; + case OWRITE: + qlock(&outlock); + if(waserror()){ + qunlock(&outlock); + nexterror(); + } + if(outopen) + error(Einuse); + outopen = 1; + poperror(); + qunlock(&outlock); + break; + case ORDWR: + qlock(&inlock); + qlock(&outlock); + if(waserror()){ + qunlock(&outlock); + qunlock(&inlock); + nexterror(); + } + if(inopen || outopen) + error(Einuse); + inopen = 1; + outopen = 1; + poperror(); + qunlock(&outlock); + qunlock(&inlock); + break; + default: + error(Ebadarg); + } +} + +void +audio_file_close(Chan *c) +{ + switch(c->mode){ + case OREAD: + qlock(&inlock); + closinput(); + inopen = 0; + qunlock(&inlock); + break; + case OWRITE: + qlock(&outlock); + closoutput(); + outopen = 0; + qunlock(&outlock); + break; + case ORDWR: + qlock(&inlock); + qlock(&outlock); + closinput(); + closoutput(); + inopen = 0; + outopen = 0; + qunlock(&outlock); + qunlock(&inlock); + break; + } +} + +long +audio_file_read(Chan *c, void *va, long count, vlong offset) +{ + long ba; + + USED(c); + USED(offset); + qlock(&inlock); + if(waserror()){ + qunlock(&inlock); + nexterror(); + } + if(!inopen) + error(Eperm); + ba = av.in.bits * av.in.chan / Bits_Per_Byte; + if(ba <= 0 || count % ba) + error(Ebadarg); + startinput(); + ringread(&inring, va, count); + poperror(); + qunlock(&inlock); + return count; +} + +long +audio_file_write(Chan *c, void *va, long count, vlong offset) +{ + long ba; + + USED(c); + USED(offset); + qlock(&outlock); + if(waserror()){ + qunlock(&outlock); + nexterror(); + } + if(!outopen) + error(Eperm); + ba = av.out.bits * av.out.chan / Bits_Per_Byte; + if(ba <= 0 || count % ba) + error(Ebadarg); + startoutput(); + ringwrite(&outring, va, count); + poperror(); + qunlock(&outlock); + return count; +} + +long +audio_ctl_write(Chan *c, void *va, long count, vlong offset) +{ + Audio_t tmpav; + + USED(c); + USED(offset); + tmpav = av; + tmpav.in.flags = 0; + tmpav.out.flags = 0; + if(!audioparse(va, count, &tmpav)) + error(Ebadarg); + + if(!canqlock(&inlock)) + error("device busy"); + if(waserror()){ + qunlock(&inlock); + nexterror(); + } + if(!canqlock(&outlock)) + error("device busy"); + if(waserror()){ + qunlock(&outlock); + nexterror(); + } + if(instarted || outstarted) + error("device busy"); + + if(tmpav.in.flags & AUDIO_MOD_FLAG){ + tmpav.in.flags = 0; + av.in = tmpav.in; + } + if(tmpav.out.flags & AUDIO_MOD_FLAG){ + tmpav.out.flags = 0; + av.out = tmpav.out; + } + + poperror(); + qunlock(&outlock); + poperror(); + qunlock(&inlock); + return count; +} diff --git a/emu/MacOSX/mkfile b/emu/MacOSX/mkfile index 50c965f22..bb3037139 100644 --- a/emu/MacOSX/mkfile +++ b/emu/MacOSX/mkfile @@ -53,6 +53,8 @@ SYSLIBS= \ -lpthread\ -framework CoreFoundation\ -framework IOKit\ + -framework CoreAudio\ + -framework AudioToolbox\ default:V: $O.$CONF diff --git a/emu/MacOSX/mkfile-g b/emu/MacOSX/mkfile-g index 5aec9f079..23410acd0 100644 --- a/emu/MacOSX/mkfile-g +++ b/emu/MacOSX/mkfile-g @@ -42,6 +42,8 @@ SYSLIBS= \ -lpthread\ -framework CoreFoundation\ -framework IOKit\ + -framework CoreAudio\ + -framework AudioToolbox\ # -framework CoreFoundation\ # -framework IOKit\ # -framework ApplicationServices\ diff --git a/emu/port/audio-tbls.c b/emu/port/audio-tbls.c index ed84bec62..fc48017ba 100644 --- a/emu/port/audio-tbls.c +++ b/emu/port/audio-tbls.c @@ -33,6 +33,7 @@ svp_t audio_enc_tbl[] = { svp_t audio_rate_tbl[] = { { "8000", 8000 }, /* 8000 samples per second */ { "11025", 11025 }, /* 11025 samples per second */ + { "16000", 16000 }, /* 16000 samples per second */ { "22050", 22050 }, /* 22050 samples per second */ { "44100", 44100 }, /* 44100 samples per second */ {nil}, diff --git a/emu/port/audio.h b/emu/port/audio.h index 5846cda8b..adf7bde32 100644 --- a/emu/port/audio.h +++ b/emu/port/audio.h @@ -50,9 +50,41 @@ enum { Qdir = 0, /* must start at 0 representing a directory */ Qaudio, - Qaudioctl + Qaudioctl, + Qaudiodev }; +/* + * Optional platform hook for choosing a host audio device by name. + * Without it the emulator inherits whatever the operating system calls + * the default, which on a desktop is often a virtual device belonging to + * an application that is not even running: capture opens, every sample is + * zero, and nothing reports a fault. A backend that can enumerate its + * devices registers these from audio_file_init(); the rest leave them + * unset and #A/audiodev says the platform cannot select a device. + * + * list fills buf..e and returns the byte count. + * select takes 1 for input, 0 for output, and a device name — the empty + * string means "follow the system default". Returns 0, or -1 when + * no device carries that name. + */ +typedef struct Audiodevops Audiodevops; +struct Audiodevops { + int (*list)(char *buf, char *e); + int (*select)(int isin, char *name); +}; + +void audio_devops_register(Audiodevops*); + +/* + * Optional live queue-cap readback. Backends that keep + * play_buffer_ms / rec_buffer_ms register a getter from + * audio_file_init. ctlsummary omits both lines when unset. + */ + +void audio_bufcaps_register(void (*get)(int *play_ms, int *rec_ms)); + + /* required external platform specific functions */ void audio_file_init(void); void audio_file_open(Chan*, int); diff --git a/emu/port/devaudio.c b/emu/port/devaudio.c index c592f88c1..fa8fbfd38 100644 --- a/emu/port/devaudio.c +++ b/emu/port/devaudio.c @@ -8,8 +8,26 @@ Dirtab audiotab[] = ".", {Qdir, 0, QTDIR}, 0, 0555, "audio", {Qaudio}, 0, 0666, "audioctl", {Qaudioctl}, 0, 0666, + "audiodev", {Qaudiodev}, 0, 0666, }; +static Audiodevops *devops; + +void +audio_devops_register(Audiodevops *ops) +{ + devops = ops; +} + +static void (*bufcaps)(int*, int*); + +void +audio_bufcaps_register(void (*get)(int *play_ms, int *rec_ms)) +{ + bufcaps = get; +} + + static void audioinit(void) { @@ -45,6 +63,7 @@ audioopen(Chan *c, int omode) switch(c->qid.path) { case Qdir: case Qaudioctl: + case Qaudiodev: break; case Qaudio: audio_file_open(c, c->mode); @@ -65,6 +84,7 @@ audioclose(Chan *c) switch(c->qid.path) { case Qdir: case Qaudioctl: + case Qaudiodev: break; case Qaudio: audio_file_close(c); @@ -98,10 +118,77 @@ audioread(Chan *c, void *va, long count, vlong offset) poperror(); free(buf); return count; + case Qaudiodev: + buf = smalloc(READSTR); + if(waserror()){ + free(buf); + nexterror(); + } + if(devops == nil || devops->list == nil) + n = snprint(buf, READSTR, "unsupported\n"); + else + n = devops->list(buf, buf + READSTR); + count = readstr(offset, va, n, buf); + poperror(); + free(buf); + return count; } return 0; } +/* + * "in " or "out " selects a host device; an empty name, or + * the literal "default", follows the system default again. The readback + * quotes names because they routinely contain spaces, so accept that + * form here too — a line from a read can be written straight back. + */ +static long +audiodevwrite(void *va, long count) +{ + char *buf, *name, *p; + int isin; + + if(devops == nil || devops->select == nil) + error("audiodev: this platform cannot select an audio device"); + if(count <= 0 || count > 256) + error(Ebadarg); + + buf = smalloc(count + 1); + if(waserror()){ + free(buf); + nexterror(); + } + memmove(buf, va, count); + buf[count] = 0; + if((p = strchr(buf, '\n')) != nil) + *p = 0; + + if(strncmp(buf, "in ", 3) == 0){ + isin = 1; + name = buf + 3; + }else if(strncmp(buf, "out ", 4) == 0){ + isin = 0; + name = buf + 4; + }else + error("audiodev: write \"in \" or \"out \""); + + while(*name == ' ' || *name == '\t') + name++; + if(*name == '\''){ + name++; + if((p = strrchr(name, '\'')) != nil) + *p = 0; + } + if(strcmp(name, "default") == 0) + *name = 0; + if(devops->select(isin, name) < 0) + error("audiodev: no device of that name"); + + poperror(); + free(buf); + return count; +} + static long audiowrite(Chan *c, void *va, long count, vlong offset) { @@ -110,6 +197,8 @@ audiowrite(Chan *c, void *va, long count, vlong offset) return audio_file_write(c, va, count, offset); case Qaudioctl: return audio_ctl_write(c, va, count, offset); + case Qaudiodev: + return audiodevwrite(va, count); } return 0; } @@ -327,6 +416,19 @@ ctlsummary(char *buf, int bsize, Audio_t *adev) p = seprint(p, e, "out %d 0 100\n", out->right); p = seprint(p, e, "in buf %d %d %d\n", in->buf, Audio_Min_Val, Audio_Max_Val); p = seprint(p, e, "out buf %d %d %d\n", out->buf, Audio_Min_Val, Audio_Max_Val); + p = seprint(p, e, "in rate %lud chans %lud bits %lud\n", + in->rate, in->chan, in->bits); + p = seprint(p, e, "out rate %lud chans %lud bits %lud\n", + out->rate, out->chan, out->bits); + if(bufcaps){ + int play_ms, rec_ms; + + bufcaps(&play_ms, &rec_ms); + p = seprint(p, e, "play_buffer_ms %d\n", play_ms); + p = seprint(p, e, "rec_buffer_ms %d\n", rec_ms); + } + + return p-buf; } diff --git a/tests/audioctl_readback_test.b b/tests/audioctl_readback_test.b new file mode 100644 index 000000000..cf140e13f --- /dev/null +++ b/tests/audioctl_readback_test.b @@ -0,0 +1,238 @@ +implement AudioctlReadbackTest; + +# +# audioctl_readback_test. +# +# audioctl accepted play_buffer_ms / rec_buffer_ms but ctlsummary only +# printed capability ranges, so a write across a 9P mount could not be +# confirmed. Inferno sh does not put write errors in $status, which made +# the missing readback expensive to notice. +# +# This test writes the caps through an export/mount of #A and reads them +# back from the mounted audioctl. It also pins the existing capability +# lines so the additive format does not break consumers. +# + +include "sys.m"; + sys: Sys; + +include "draw.m"; + +include "testing.m"; + testing: Testing; + T: import testing; + +AudioctlReadbackTest: module { + init: fn(nil: ref Draw->Context, args: list of string); +}; + +SRCFILE: con "/tests/audioctl_readback_test.b"; + +passed := 0; +failed := 0; +skipped := 0; + +MNT: con "/n/phone"; + + + +run(name: string, testfn: ref fn(t: ref T)) +{ + t := testing->newTsrc(name, SRCFILE); + { + testfn(t); + } exception { + "fail:fatal" => + ; + "fail:skip" => + ; + * => + t.failed = 1; + } + if(testing->done(t)) + passed++; + else if(t.skipped) + skipped++; + else + failed++; +} + +contains(haystack, needle: string): int +{ + if(len needle == 0) + return 1; + for(i := 0; i + len needle <= len haystack; i++) + if(haystack[i:i + len needle] == needle) + return 1; + return 0; +} + +# Value of `key value` on its own line, or "". +extract(s, key: string): string +{ + i := 0; + while(i < len s) { + j := i; + while(j < len s && s[j] != '\n') + j++; + line := s[i:j]; + if(len line > len key && line[:len key] == key && line[len key] == ' ') + return line[len key + 1:]; + i = j + 1; + } + return ""; +} + +readfile(path: string): string +{ + fd := sys->open(path, Sys->OREAD); + if(fd == nil) + return nil; + buf := array[4096] of byte; + n := sys->read(fd, buf, len buf); + if(n <= 0) + return ""; + return string buf[:n]; +} + +writefile(path, content: string): int +{ + fd := sys->open(path, Sys->OWRITE); + if(fd == nil) + return -1; + b := array of byte content; + return sys->write(fd, b, len b); +} + +exporter(fd: ref Sys->FD) +{ + sys->export(fd, "/dev", Sys->EXPWAIT); +} + +mkdir(path: string) +{ + (ok, nil) := sys->stat(path); + if(ok >= 0) + return; + sys->create(path, Sys->OREAD, Sys->DMDIR | 8r755); +} + +# Bind #A onto /dev, export it over a pipe, mount at /n/phone. +# Same shape as voice/listen: a 9P view of the device tree. +mountadev(t: ref T) +{ + if(sys->bind("#A", "/dev", Sys->MAFTER) < 0) + t.fatal(sys->sprint("bind #A: %r")); + + p := array[2] of ref Sys->FD; + if(sys->pipe(p) < 0) + t.fatal(sys->sprint("pipe: %r")); + + mkdir("/n"); + sys->unmount(nil, MNT); + mkdir(MNT); + + spawn exporter(p[0]); + if(sys->mount(p[1], nil, MNT, Sys->MREPL, nil) < 0) + t.fatal(sys->sprint("mount: %r")); +} + + +unmountadev() +{ + sys->unmount(nil, MNT); +} + +testCapsRoundTripOver9P(t: ref T) +{ + caught := 0; + { + mountadev(t); + + # Skip on absence of the mechanism, not a wrong value. + # Linux/OSS never registers, so the line is omitted. + # A present line with the wrong number is a FAIL. + s0 := readfile(MNT + "/audioctl"); + if(!contains(s0, "play_buffer_ms")) { + t.skip("this audio backend registers no buffer-cap accessor"); + return; + } + + + + + n := writefile(MNT + "/audioctl", "play_buffer_ms 80\n"); + t.assert(n > 0, "write play_buffer_ms across 9P"); + n = writefile(MNT + "/audioctl", "rec_buffer_ms 120\n"); + t.assert(n > 0, "write rec_buffer_ms across 9P"); + + s := readfile(MNT + "/audioctl"); + t.assertnotnil(s, "read audioctl across 9P"); + t.log(s); + + t.assertseq(extract(s, "play_buffer_ms"), "80", + "play_buffer_ms read back across 9P"); + t.assertseq(extract(s, "rec_buffer_ms"), "120", + "rec_buffer_ms read back across 9P"); + + # Existing capability-range lines stay, first value current. + t.assert(contains(s, "rate 8000 11025 16000 22050 44100"), + "capability rate line unchanged"); + t.assert(contains(s, "bits 16 8") || contains(s, "bits 16"), + "capability bits line still present"); + t.assert(contains(s, "chans 2 1") || contains(s, "chans 2"), + "capability chans line still present"); + t.assert(contains(s, "in buf"), + "capability in buf line still present"); + t.assert(contains(s, "out buf"), + "capability out buf line still present"); + + t.assertseq(extract(s, "in rate"), "8000 chans 2 bits 16", + "live in format reported"); + t.assertseq(extract(s, "out rate"), "8000 chans 2 bits 16", + "live out format reported"); + + n = writefile(MNT + "/audioctl", "play_buffer_ms 40\n"); + t.assert(n > 0, "overwrite play_buffer_ms"); + s = readfile(MNT + "/audioctl"); + t.assertseq(extract(s, "play_buffer_ms"), "40", + "second write is the live value, not a stale first write"); + t.assertseq(extract(s, "rec_buffer_ms"), "120", + "unrelated cap is unchanged"); + } exception { + "fail:fatal" => + caught = 1; + "fail:skip" => + caught = 2; + * => + caught = 3; + } + unmountadev(); + if(caught == 1) + raise "fail:fatal"; + if(caught == 2) + raise "fail:skip"; + if(caught == 3) + t.failed = 1; +} + + +init(nil: ref Draw->Context, args: list of string) +{ + sys = load Sys Sys->PATH; + testing = load Testing Testing->PATH; + if(testing == nil) { + sys->fprint(sys->fildes(2), "cannot load testing module: %r\n"); + raise "fail:cannot load testing"; + } + testing->init(); + + for(a := args; a != nil; a = tl a) + if(hd a == "-v") + testing->verbose(1); + + run("CapsRoundTripOver9P", testCapsRoundTripOver9P); + + if(testing->summary(passed, failed, skipped) > 0) + raise "fail:tests failed"; +} diff --git a/tests/host/audio_device_select_test.sh b/tests/host/audio_device_select_test.sh new file mode 100755 index 000000000..7f2397314 --- /dev/null +++ b/tests/host/audio_device_select_test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# #A/audiodev: enumerate host audio devices, select one by name, and say +# whether the capture that ran actually heard anything. +# +# The last part is the point. A device that opens and returns nothing but +# zeroes — a virtual input whose application is not running, an OS-muted +# device, a missing microphone authorization — is indistinguishable from a +# working microphone in a quiet room at every other layer. +set -euo pipefail + +ROOT=${ROOT:-$(cd "$(dirname "$0")/../.." && pwd)} +cd "$ROOT" +export ROOT +export PATH="$ROOT/MacOSX/arm64/bin:$PATH" + +EMU=${EMU:-./emu/MacOSX/o.emu} +TMPDIR_=${AUDIO_TEST_TMPDIR:-.omx/tmp} +mkdir -p "$TMPDIR_" +log="$TMPDIR_/audiodev-test.log" + +skip() { echo "SKIP: $1"; exit 77; } +fail() { echo "FAIL: $1"; exit 1; } + +run_inferno() { + "$EMU" -r. /dis/sh.dis -c "$1" >"$log" 2>&1 || true + cat "$log" +} + +# 1. The file exists and enumerates. A backend without device selection +# says so rather than pretending; that is a skip, not a failure. +run_inferno "bind -a '#A' /dev; cat /dev/audiodev" >/dev/null +if grep -q '^unsupported' "$log"; then + skip "this build has no audio device selection (headless/non-SDL3 backend)" +fi +if grep -q '^unavailable' "$log"; then + skip "the audio subsystem is unavailable in this host session" +fi +grep -q "^in selected " "$log" || fail "no input selection reported" +grep -q "^out selected " "$log" || fail "no output selection reported" + +# 2. An unknown name is refused rather than silently ignored — otherwise a +# typo leaves you on the wrong device with no signal. +run_inferno "bind -a '#A' /dev; echo 'in No Such Device Exists' > /dev/audiodev" >/dev/null +grep -q "no device of that name" "$log" || + fail "an unknown device name was accepted" + +# 3. A name from the enumeration round-trips through the readback. +dev=$(run_inferno "bind -a '#A' /dev; cat /dev/audiodev" | + awk -F"'" '/^in device /{print $2; exit}') +[ -n "$dev" ] || skip "no input devices on this host" +run_inferno "bind -a '#A' /dev; echo 'in $dev' > /dev/audiodev; cat /dev/audiodev" >/dev/null +grep -qF "in selected '$dev'" "$log" || + fail "selecting '$dev' did not read back" + +# 4. "default" returns to following the system default. +run_inferno "bind -a '#A' /dev; echo 'in $dev' > /dev/audiodev; echo 'in default' > /dev/audiodev; cat /dev/audiodev" >/dev/null +grep -q "^in selected default" "$log" || + fail "'in default' did not restore the system default" + +# 5. A capture reports whether it heard anything. Without a usable +# microphone this can only reach "silent", which is itself the verdict +# the selection exists to surface — so both outcomes pass here, and only +# a missing verdict fails. +run_inferno "bind -a '#A' /dev; echo 'in rate 16000 chans 1 bits 16 enc pcm' > /dev/audioctl; dd -if /dev/audio -of /tmp/audiodev-cap.pcm -bs 16000 -count 8; cat /dev/audiodev" >/dev/null +if grep -q "^capture active" "$log"; then + echo "capture verdict: active" +elif grep -q "^capture silent" "$log"; then + echo "SKIP: capture ran but heard only silence (no usable microphone here)" +elif grep -q "^capture idle" "$log"; then + echo "SKIP: the capture device delivered no data in this host session" +else + fail "no capture verdict reported after a capture" +fi + +echo "PASS: audio devices enumerate, select by name, and report what they heard" +echo "PASS" diff --git a/tests/host/audio_macos_test.sh b/tests/host/audio_macos_test.sh new file mode 100755 index 000000000..74c96977a --- /dev/null +++ b/tests/host/audio_macos_test.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=${ROOT:-$(cd "$(dirname "$0")/../.." && pwd)} +cd "$ROOT" +export ROOT +export PATH="$ROOT/MacOSX/arm64/bin:$PATH" + +EMU=${EMU:-./emu/MacOSX/o.emu} +AUDIODIR=${AUDIO_TEST_TMPDIR:-.omx/tmp} +mkdir -p "$AUDIODIR" + +make_pcm() { + python3 - "$1" "$2" "$3" <<'PY' +import math, struct, sys +path, rate, seconds = sys.argv[1], int(sys.argv[2]), float(sys.argv[3]) +frames = int(rate * seconds) +with open(path, 'wb') as f: + for i in range(frames): + sample = int(12000 * math.sin(2 * math.pi * 440 * i / rate)) + f.write(struct.pack('"$log" 2>&1; then + cat "$log" + assert_devices_or_skip "$log" || return 1 + if grep -Eq "cannot start CoreAudio (input|output): -66680" "$log"; then + rm -f "$AUDIODIR/audio-capture.pcm" + echo "SKIP: CoreAudio device unavailable in this host session" + fi + return 0 + fi + cat "$log" + assert_devices_or_skip "$log" || return 1 + if grep -Eq "cannot start CoreAudio (input|output): -66680" "$log"; then + rm -f "$AUDIODIR/audio-capture.pcm" + echo "SKIP: CoreAudio device unavailable in this host session" + return 0 + fi + return 1 +} + +mode=${1:-roundtrip} +case "$mode" in +ctl) + run_inferno "bind -a '#A' /dev; ls /dev/audio /dev/audioctl; cat /dev/audioctl" + ;; +playback) + make_pcm "$AUDIODIR/audio-playback.pcm" 16000 0.25 + run_audio_inferno "bind -a '#A' /dev; echo 'out rate 16000 chans 1 bits 16 enc pcm' > /dev/audioctl; cat /$AUDIODIR/audio-playback.pcm > /dev/audio" + ;; +capture) + rm -f "$AUDIODIR/audio-capture.pcm" + run_audio_inferno "bind -a '#A' /dev; echo 'in rate 16000 chans 1 bits 16 enc pcm' > /dev/audioctl; dd -if /dev/audio -of /$AUDIODIR/audio-capture.pcm -bs 32000 -count 1" + if [ -e "$AUDIODIR/audio-capture.pcm" ] && [ ! -s "$AUDIODIR/audio-capture.pcm" ]; then + # The device opened but delivered no frames. On macOS this is the + # microphone-permission (TCC) posture for non-interactive shells and + # CI: the input AudioQueue starts but never gets buffers. Same skip + # philosophy as the -66680 device-unavailable case above — a real + # capture regression can only be asserted where a mic is usable. + echo "SKIP: no audio captured (microphone unavailable or permission denied in this host session)" + fi + ;; +roundtrip) + "$0" playback + "$0" capture + ;; +*) + echo "usage: $0 [ctl|playback|capture|roundtrip]" >&2 + exit 2 + ;; +esac diff --git a/tests/mkfile b/tests/mkfile index aa1736091..24a5368b9 100644 --- a/tests/mkfile +++ b/tests/mkfile @@ -19,6 +19,7 @@ TARG=\ tooluse_test.dis\ awk_test.dis\ asyncio_test.dis\ + audioctl_readback_test.dis\ arrayinit_test.dis\ bufio_test.dis\ kbddrainer_test.dis\ diff --git a/tools/dis-manifest.txt b/tools/dis-manifest.txt index bd2e6e899..992b57d5e 100644 --- a/tools/dis-manifest.txt +++ b/tools/dis-manifest.txt @@ -650,6 +650,7 @@ dis/tests/agentlib_test.dis dis/tests/alpha_test.dis dis/tests/arrayinit_test.dis dis/tests/asyncio_test.dis +dis/tests/audioctl_readback_test.dis dis/tests/auditchain_test.dis dis/tests/auditprov_test.dis dis/tests/auditsign_test.dis From a40f0ff83b0f45437e1da66f4a38cff9e8a5718a Mon Sep 17 00:00:00 2001 From: Teodor Date: Mon, 31 Aug 2026 18:25:30 +0800 Subject: [PATCH 2/6] fix(audio): drop the CoreAudio backend nothing builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No macOS configuration compiles emu/MacOSX/audio.c: the emu config lists 'audio audio-sdl3', emu-g has audio commented out, and both builds link audio-sdl3.o. It registers no Audiodevops, so it could not perform device selection even if it were wired in. Remove it and the -framework CoreAudio/-framework AudioToolbox flags added for it — link flags for an object that is never linked. audio_macos_test.sh grepped for coreerror() text only that file could emit; that branch was dead too and is gone. The empty-capture skip now exits 77 like every other skip, and the capture asserts a size floor instead of passing on a handful of bytes. --- emu/MacOSX/audio.c | 531 --------------------------------- emu/MacOSX/mkfile | 2 - emu/MacOSX/mkfile-g | 2 - tests/host/audio_macos_test.sh | 23 +- 4 files changed, 11 insertions(+), 547 deletions(-) delete mode 100644 emu/MacOSX/audio.c diff --git a/emu/MacOSX/audio.c b/emu/MacOSX/audio.c deleted file mode 100644 index c39277b85..000000000 --- a/emu/MacOSX/audio.c +++ /dev/null @@ -1,531 +0,0 @@ -#include "dat.h" -#include "fns.h" -#include "error.h" -#include "audio.h" -#include -#include -#include - -#define Audio_Mic_Val 1 -#define Audio_Linein_Val 2 - -#define Audio_Speaker_Val 1 -#define Audio_Headphone_Val 2 -#define Audio_Lineout_Val 3 - -#define Audio_Pcm_Val 1 -#define Audio_Ulaw_Val 2 -#define Audio_Alaw_Val 3 - -#include "audio-tbls.c" - -#define Nqueuebuf 3 -#define Defbufsz 8192 -#define Defringsz 262144 - -#define min(a,b) ((a) < (b) ? (a) : (b)) - -typedef struct Ring Ring; -struct Ring { - uchar *data; - int size; - int r; - int w; - int fill; - pthread_mutex_t lk; - pthread_cond_t canread; - pthread_cond_t canwrite; -}; - -static Audio_t av; -static QLock inlock; -static QLock outlock; - -static int inopen; -static int outopen; -static int instarted; -static int outstarted; -static Ring inring; -static Ring outring; -static AudioQueueRef inq; -static AudioQueueRef outq; -static AudioQueueBufferRef inbuf[Nqueuebuf]; -static AudioQueueBufferRef outbuf[Nqueuebuf]; -static AudioStreamBasicDescription infmt; -static AudioStreamBasicDescription outfmt; - -static void closinput(void); -static void closoutput(void); -static void startinput(void); -static void startoutput(void); - -Audio_t* -getaudiodev(void) -{ - return &av; -} - -void -audio_file_init(void) -{ - audio_info_init(&av); -} - -static void -coreerror(char *what, OSStatus s) -{ - char buf[ERRMAX]; - - snprint(buf, sizeof(buf), "%s: %ld", what, (long)s); - error(buf); -} - -static void -ringinit(Ring *r, int n) -{ - if(r->data != nil && r->size == n) - return; - if(r->data != nil) - free(r->data); - r->data = malloc(n); - if(r->data == nil) - error(Enomem); - r->size = n; - r->r = 0; - r->w = 0; - r->fill = 0; - pthread_mutex_init(&r->lk, nil); - pthread_cond_init(&r->canread, nil); - pthread_cond_init(&r->canwrite, nil); -} - -static void -ringreset(Ring *r) -{ - pthread_mutex_lock(&r->lk); - r->r = 0; - r->w = 0; - r->fill = 0; - pthread_cond_broadcast(&r->canread); - pthread_cond_broadcast(&r->canwrite); - pthread_mutex_unlock(&r->lk); -} - -static void -ringfree(Ring *r) -{ - uchar *p; - - if(r->data == nil) - return; - pthread_mutex_lock(&r->lk); - p = r->data; - r->data = nil; - r->size = 0; - r->r = 0; - r->w = 0; - r->fill = 0; - pthread_cond_broadcast(&r->canread); - pthread_cond_broadcast(&r->canwrite); - pthread_mutex_unlock(&r->lk); - free(p); - pthread_cond_destroy(&r->canread); - pthread_cond_destroy(&r->canwrite); - pthread_mutex_destroy(&r->lk); -} - -static int -ringreadnb(Ring *r, uchar *p, int n) -{ - int m, got; - - got = 0; - pthread_mutex_lock(&r->lk); - while(got < n && r->fill > 0){ - m = min(n - got, r->fill); - m = min(m, r->size - r->r); - memcpy(p + got, r->data + r->r, m); - r->r = (r->r + m) % r->size; - r->fill -= m; - got += m; - } - if(got > 0) - pthread_cond_broadcast(&r->canwrite); - pthread_mutex_unlock(&r->lk); - return got; -} - -static int -ringwritenb(Ring *r, uchar *p, int n) -{ - int m, put; - - put = 0; - pthread_mutex_lock(&r->lk); - while(put < n && r->fill < r->size){ - m = min(n - put, r->size - r->fill); - m = min(m, r->size - r->w); - memcpy(r->data + r->w, p + put, m); - r->w = (r->w + m) % r->size; - r->fill += m; - put += m; - } - if(put > 0) - pthread_cond_broadcast(&r->canread); - pthread_mutex_unlock(&r->lk); - return put; -} - -static void -ringwrite(Ring *r, uchar *p, int n) -{ - int m; - - pthread_mutex_lock(&r->lk); - while(n > 0){ - while(r->fill == r->size) - pthread_cond_wait(&r->canwrite, &r->lk); - m = min(n, r->size - r->fill); - m = min(m, r->size - r->w); - memcpy(r->data + r->w, p, m); - r->w = (r->w + m) % r->size; - r->fill += m; - p += m; - n -= m; - pthread_cond_broadcast(&r->canread); - } - pthread_mutex_unlock(&r->lk); -} - -static void -ringread(Ring *r, uchar *p, int n) -{ - int m; - - pthread_mutex_lock(&r->lk); - while(n > 0){ - while(r->fill == 0) - pthread_cond_wait(&r->canread, &r->lk); - m = min(n, r->fill); - m = min(m, r->size - r->r); - memcpy(p, r->data + r->r, m); - r->r = (r->r + m) % r->size; - r->fill -= m; - p += m; - n -= m; - pthread_cond_broadcast(&r->canwrite); - } - pthread_mutex_unlock(&r->lk); -} - -static void -setformat(Audio_d *d, AudioStreamBasicDescription *fmt) -{ - if(d->enc != Audio_Pcm_Val) - error("unsupported macOS audio encoding"); - if(d->bits != 8 && d->bits != 16) - error("unsupported macOS audio sample size"); - if(d->chan != 1 && d->chan != 2) - error("unsupported macOS audio channel count"); - memset(fmt, 0, sizeof(*fmt)); - fmt->mSampleRate = d->rate; - fmt->mFormatID = kAudioFormatLinearPCM; - fmt->mFormatFlags = kLinearPCMFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked; - fmt->mBitsPerChannel = d->bits; - fmt->mChannelsPerFrame = d->chan; - fmt->mFramesPerPacket = 1; - fmt->mBytesPerFrame = (d->bits / Bits_Per_Byte) * d->chan; - fmt->mBytesPerPacket = fmt->mBytesPerFrame; -} - -static int -ringsz(Audio_d *d) -{ - int n; - - n = d->buf * Audio_Max_Buf / Audio_Max_Val; - if(n < Defbufsz) - n = Defbufsz; - if(n < Defringsz) - n = Defringsz; - return n; -} - -static void -outcallback(void *arg, AudioQueueRef q, AudioQueueBufferRef b) -{ - int n; - - USED(arg); - n = ringreadnb(&outring, b->mAudioData, b->mAudioDataBytesCapacity); - if(n < (int)b->mAudioDataBytesCapacity) - memset((uchar*)b->mAudioData + n, 0, b->mAudioDataBytesCapacity - n); - b->mAudioDataByteSize = b->mAudioDataBytesCapacity; - AudioQueueEnqueueBuffer(q, b, 0, nil); -} - -static void -incallback(void *arg, AudioQueueRef q, AudioQueueBufferRef b, - const AudioTimeStamp *start, UInt32 packets, const AudioStreamPacketDescription *desc) -{ - USED(arg); - USED(start); - USED(packets); - USED(desc); - if(b->mAudioDataByteSize > 0) - ringwritenb(&inring, b->mAudioData, b->mAudioDataByteSize); - AudioQueueEnqueueBuffer(q, b, 0, nil); -} - -static void -startoutput(void) -{ - OSStatus s; - int i; - - if(outstarted) - return; - setformat(&av.out, &outfmt); - ringinit(&outring, ringsz(&av.out)); - ringreset(&outring); - s = AudioQueueNewOutput(&outfmt, outcallback, nil, nil, nil, 0, &outq); - if(s != noErr) - coreerror("cannot open CoreAudio output", s); - for(i = 0; i < Nqueuebuf; i++){ - s = AudioQueueAllocateBuffer(outq, Defbufsz, &outbuf[i]); - if(s != noErr) - coreerror("cannot allocate CoreAudio output buffer", s); - memset(outbuf[i]->mAudioData, 0, Defbufsz); - outbuf[i]->mAudioDataByteSize = Defbufsz; - s = AudioQueueEnqueueBuffer(outq, outbuf[i], 0, nil); - if(s != noErr) - coreerror("cannot enqueue CoreAudio output buffer", s); - } - s = AudioQueueStart(outq, nil); - if(s != noErr) - coreerror("cannot start CoreAudio output", s); - outstarted = 1; -} - -static void -startinput(void) -{ - OSStatus s; - int i; - - if(instarted) - return; - setformat(&av.in, &infmt); - ringinit(&inring, ringsz(&av.in)); - ringreset(&inring); - s = AudioQueueNewInput(&infmt, incallback, nil, nil, nil, 0, &inq); - if(s != noErr) - coreerror("cannot open CoreAudio input", s); - for(i = 0; i < Nqueuebuf; i++){ - s = AudioQueueAllocateBuffer(inq, Defbufsz, &inbuf[i]); - if(s != noErr) - coreerror("cannot allocate CoreAudio input buffer", s); - s = AudioQueueEnqueueBuffer(inq, inbuf[i], 0, nil); - if(s != noErr) - coreerror("cannot enqueue CoreAudio input buffer", s); - } - s = AudioQueueStart(inq, nil); - if(s != noErr) - coreerror("cannot start CoreAudio input", s); - instarted = 1; -} - -static void -closoutput(void) -{ - if(outq != nil){ - AudioQueueStop(outq, true); - AudioQueueDispose(outq, true); - outq = nil; - } - outstarted = 0; - ringfree(&outring); -} - -static void -closinput(void) -{ - if(inq != nil){ - AudioQueueStop(inq, true); - AudioQueueDispose(inq, true); - inq = nil; - } - instarted = 0; - ringfree(&inring); -} - -void -audio_file_open(Chan *c, int omode) -{ - USED(c); - switch(omode){ - case OREAD: - qlock(&inlock); - if(waserror()){ - qunlock(&inlock); - nexterror(); - } - if(inopen) - error(Einuse); - inopen = 1; - poperror(); - qunlock(&inlock); - break; - case OWRITE: - qlock(&outlock); - if(waserror()){ - qunlock(&outlock); - nexterror(); - } - if(outopen) - error(Einuse); - outopen = 1; - poperror(); - qunlock(&outlock); - break; - case ORDWR: - qlock(&inlock); - qlock(&outlock); - if(waserror()){ - qunlock(&outlock); - qunlock(&inlock); - nexterror(); - } - if(inopen || outopen) - error(Einuse); - inopen = 1; - outopen = 1; - poperror(); - qunlock(&outlock); - qunlock(&inlock); - break; - default: - error(Ebadarg); - } -} - -void -audio_file_close(Chan *c) -{ - switch(c->mode){ - case OREAD: - qlock(&inlock); - closinput(); - inopen = 0; - qunlock(&inlock); - break; - case OWRITE: - qlock(&outlock); - closoutput(); - outopen = 0; - qunlock(&outlock); - break; - case ORDWR: - qlock(&inlock); - qlock(&outlock); - closinput(); - closoutput(); - inopen = 0; - outopen = 0; - qunlock(&outlock); - qunlock(&inlock); - break; - } -} - -long -audio_file_read(Chan *c, void *va, long count, vlong offset) -{ - long ba; - - USED(c); - USED(offset); - qlock(&inlock); - if(waserror()){ - qunlock(&inlock); - nexterror(); - } - if(!inopen) - error(Eperm); - ba = av.in.bits * av.in.chan / Bits_Per_Byte; - if(ba <= 0 || count % ba) - error(Ebadarg); - startinput(); - ringread(&inring, va, count); - poperror(); - qunlock(&inlock); - return count; -} - -long -audio_file_write(Chan *c, void *va, long count, vlong offset) -{ - long ba; - - USED(c); - USED(offset); - qlock(&outlock); - if(waserror()){ - qunlock(&outlock); - nexterror(); - } - if(!outopen) - error(Eperm); - ba = av.out.bits * av.out.chan / Bits_Per_Byte; - if(ba <= 0 || count % ba) - error(Ebadarg); - startoutput(); - ringwrite(&outring, va, count); - poperror(); - qunlock(&outlock); - return count; -} - -long -audio_ctl_write(Chan *c, void *va, long count, vlong offset) -{ - Audio_t tmpav; - - USED(c); - USED(offset); - tmpav = av; - tmpav.in.flags = 0; - tmpav.out.flags = 0; - if(!audioparse(va, count, &tmpav)) - error(Ebadarg); - - if(!canqlock(&inlock)) - error("device busy"); - if(waserror()){ - qunlock(&inlock); - nexterror(); - } - if(!canqlock(&outlock)) - error("device busy"); - if(waserror()){ - qunlock(&outlock); - nexterror(); - } - if(instarted || outstarted) - error("device busy"); - - if(tmpav.in.flags & AUDIO_MOD_FLAG){ - tmpav.in.flags = 0; - av.in = tmpav.in; - } - if(tmpav.out.flags & AUDIO_MOD_FLAG){ - tmpav.out.flags = 0; - av.out = tmpav.out; - } - - poperror(); - qunlock(&outlock); - poperror(); - qunlock(&inlock); - return count; -} diff --git a/emu/MacOSX/mkfile b/emu/MacOSX/mkfile index bb3037139..50c965f22 100644 --- a/emu/MacOSX/mkfile +++ b/emu/MacOSX/mkfile @@ -53,8 +53,6 @@ SYSLIBS= \ -lpthread\ -framework CoreFoundation\ -framework IOKit\ - -framework CoreAudio\ - -framework AudioToolbox\ default:V: $O.$CONF diff --git a/emu/MacOSX/mkfile-g b/emu/MacOSX/mkfile-g index 23410acd0..5aec9f079 100644 --- a/emu/MacOSX/mkfile-g +++ b/emu/MacOSX/mkfile-g @@ -42,8 +42,6 @@ SYSLIBS= \ -lpthread\ -framework CoreFoundation\ -framework IOKit\ - -framework CoreAudio\ - -framework AudioToolbox\ # -framework CoreFoundation\ # -framework IOKit\ # -framework ApplicationServices\ diff --git a/tests/host/audio_macos_test.sh b/tests/host/audio_macos_test.sh index 74c96977a..4144b4fc8 100755 --- a/tests/host/audio_macos_test.sh +++ b/tests/host/audio_macos_test.sh @@ -42,19 +42,10 @@ run_audio_inferno() { if run_inferno "$1" >"$log" 2>&1; then cat "$log" assert_devices_or_skip "$log" || return 1 - if grep -Eq "cannot start CoreAudio (input|output): -66680" "$log"; then - rm -f "$AUDIODIR/audio-capture.pcm" - echo "SKIP: CoreAudio device unavailable in this host session" - fi return 0 fi cat "$log" assert_devices_or_skip "$log" || return 1 - if grep -Eq "cannot start CoreAudio (input|output): -66680" "$log"; then - rm -f "$AUDIODIR/audio-capture.pcm" - echo "SKIP: CoreAudio device unavailable in this host session" - return 0 - fi return 1 } @@ -70,14 +61,22 @@ playback) capture) rm -f "$AUDIODIR/audio-capture.pcm" run_audio_inferno "bind -a '#A' /dev; echo 'in rate 16000 chans 1 bits 16 enc pcm' > /dev/audioctl; dd -if /dev/audio -of /$AUDIODIR/audio-capture.pcm -bs 32000 -count 1" - if [ -e "$AUDIODIR/audio-capture.pcm" ] && [ ! -s "$AUDIODIR/audio-capture.pcm" ]; then + if [ ! -s "$AUDIODIR/audio-capture.pcm" ]; then # The device opened but delivered no frames. On macOS this is the # microphone-permission (TCC) posture for non-interactive shells and - # CI: the input AudioQueue starts but never gets buffers. Same skip - # philosophy as the -66680 device-unavailable case above — a real + # CI: the input AudioQueue starts but never gets buffers. A real # capture regression can only be asserted where a mic is usable. echo "SKIP: no audio captured (microphone unavailable or permission denied in this host session)" + exit 77 + fi + # A capture that delivered 738 of 32000 bytes must not pass the same + # check as a healthy one: assert a floor of 0.25 s at 16 kHz mono s16. + capsize=$(wc -c < "$AUDIODIR/audio-capture.pcm" | tr -d ' ') + if [ "$capsize" -lt 8000 ]; then + echo "FAIL: capture delivered only ${capsize} of 32000 bytes" + exit 1 fi + echo "capture delivered ${capsize} of 32000 bytes" ;; roundtrip) "$0" playback From 6491df0e603877043bd7107938ce488bb0b08701 Mon Sep 17 00:00:00 2001 From: Teodor Date: Mon, 31 Aug 2026 18:29:06 +0800 Subject: [PATCH 3/6] fix(audio): accept a read line written back to #A/audiodev The comment in audiodevwrite and docs/SPEECH-REMOTE-AUDIO.md claimed a line from a read of #A/audiodev can be written straight back. It could not: the parser stripped the direction word but not the device/selected keyword, so every form the read actually emits was refused with 'no device of that name'. Teach the parser to skip that keyword (only when a name follows it, so a device literally called device stays reachable) and restate the accepted forms in the doc. Also document INFERNODE_AUDIO_IN / INFERNODE_AUDIO_OUT there, which were shipped undocumented. --- docs/SPEECH-REMOTE-AUDIO.md | 20 +++++++++++++++----- emu/port/devaudio.c | 15 +++++++++++++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/SPEECH-REMOTE-AUDIO.md b/docs/SPEECH-REMOTE-AUDIO.md index 38efb94c3..bed79a626 100644 --- a/docs/SPEECH-REMOTE-AUDIO.md +++ b/docs/SPEECH-REMOTE-AUDIO.md @@ -99,16 +99,26 @@ capture idle ``` Write `in ` or `out ` to select one. Names are quoted in the -readback because they contain spaces, and either form is accepted on write, so -a line from the read can be written straight back. `in default` returns to -following the host default. An unknown name is refused rather than ignored. -Selection takes effect at once: a stream that is already open is reopened on -the new device. +readback because they contain spaces, and the readback's own forms — +`in device ''` and `in selected ''` — are accepted on write +as well, so a line from the read can be written straight back. `in +default` returns to following the host default. An unknown name is +refused rather than ignored. Selection takes effect at once: a stream +that is already open is reopened on the new device. ```sh echo 'in Built-in Microphone' > /dev/audiodev +echo "$(cat /dev/audiodev | grep '^in device' | head -1)" > /dev/audiodev ``` +A launcher that already knows the right device can set it before the +emulator starts, which matters when the voice stack opens capture +during startup — before anything could write `#A/audiodev`. The +environment variables `INFERNODE_AUDIO_IN` and `INFERNODE_AUDIO_OUT` +take a device name each and preselect the input and output device; +an unknown name is not validated at startup but warned about and +fallen back to the system default at open time. + The last line is the diagnostic. `capture active` means the device produced at least one non-zero sample. `capture silent` means it delivered data and all of it was zero. `capture idle` means it delivered nothing. It describes the most diff --git a/emu/port/devaudio.c b/emu/port/devaudio.c index fa8fbfd38..5b947128b 100644 --- a/emu/port/devaudio.c +++ b/emu/port/devaudio.c @@ -139,8 +139,10 @@ audioread(Chan *c, void *va, long count, vlong offset) /* * "in " or "out " selects a host device; an empty name, or * the literal "default", follows the system default again. The readback - * quotes names because they routinely contain spaces, so accept that - * form here too — a line from a read can be written straight back. + * emits "in device 'X'" and "in selected 'X'" — quotes because real + * names contain spaces, and the keyword for the device's role in the + * listing — so those forms are accepted here too: a line from a read + * can be written straight back. */ static long audiodevwrite(void *va, long count) @@ -172,6 +174,15 @@ audiodevwrite(void *va, long count) }else error("audiodev: write \"in \" or \"out \""); + while(*name == ' ' || *name == '\t') + name++; + /* Skip the keyword the readback puts between the direction and the + * name. A device actually called "device" is still reachable: the + * word is only skipped when another word follows it. */ + if(strncmp(name, "device", 6) == 0 && (name[6] == ' ' || name[6] == '\t')) + name += 6; + else if(strncmp(name, "selected", 8) == 0 && (name[8] == ' ' || name[8] == '\t')) + name += 8; while(*name == ' ' || *name == '\t') name++; if(*name == '\''){ From e300298e54247799bc2916ef01c9f985c8548897 Mon Sep 17 00:00:00 2001 From: Teodor Date: Mon, 31 Aug 2026 18:29:18 +0800 Subject: [PATCH 4/6] fix(audio): bound the SDL probe and guard QuitSubSystem against streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure_sdl_audio re-ran its 20x50 ms SDL_Init/Quit probe on every call when the host enumerates no audio devices, so each /dev/audio open and audioctl read blocked a second or more, forever. Probe once and remember the result: sdl_quiet_host short-circuits the churn while the subsystem stays up, so SDL still sees devices that appear later. SDL_QuitSubSystem(SDL_INIT_AUDIO) was called without checking for live streams, in both the re-probe path and the 'not initialized' recovery in open_stream. Quitting under an open stream leaves in_stream / out_stream dangling and the next SDL_DestroyAudioStream is a use-after-free — the unplug-mid-capture race. Both sites now check sdl_streams_live() and leave the subsystem up instead. --- emu/MacOSX/audio-sdl3.c | 44 ++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/emu/MacOSX/audio-sdl3.c b/emu/MacOSX/audio-sdl3.c index 0273d64b3..7ac8fe724 100644 --- a/emu/MacOSX/audio-sdl3.c +++ b/emu/MacOSX/audio-sdl3.c @@ -137,6 +137,7 @@ static void audio_platform_init(void) { } #endif static int sdl_audio_inited; /* SDL_InitSubSystem(SDL_INIT_AUDIO) done? */ +static int sdl_quiet_host; /* probed once: the host has no audio devices */ static SDL_AudioStream *in_stream; /* mic capture */ static SDL_AudioStream *out_stream; /* speaker playback */ static QLock inlock; @@ -203,6 +204,12 @@ audio_devices_present(void) return nrec > 0 || nplay > 0; } +static int +sdl_streams_live(void) +{ + return in_stream != NULL || out_stream != NULL; +} + static int ensure_sdl_audio(void) { @@ -210,9 +217,21 @@ ensure_sdl_audio(void) /* An earlier caller may have left the subsystem up with an empty * device list. That state never recovers on its own, so re-init - * rather than trusting the flag. */ + * rather than trusting the flag — but only once: a host that came + * up with no devices stays device-less as far as this probe is + * concerned, and re-running the bounded probe below on every call + * would block each /dev/audio open and audioctl read for a second + * or more, forever. SDL still notices devices that appear later: + * the subsystem is left up, and SDL3 refreshes its device list + * while it runs. */ if(sdl_audio_inited) { - if(audio_devices_present()) + if(sdl_quiet_host || audio_devices_present()) + return 1; + /* Tearing the subsystem down under a live stream leaves + * the stream pointer dangling; the next + * SDL_DestroyAudioStream would be a use-after-free. Leave + * it up instead. */ + if(sdl_streams_live()) return 1; SDL_QuitSubSystem(SDL_INIT_AUDIO); sdl_audio_inited = 0; @@ -257,6 +276,8 @@ ensure_sdl_audio(void) SDL_Delay(AUDIO_INIT_DELAY_MS); } sdl_audio_inited = 1; + if(!audio_devices_present()) + sdl_quiet_host = 1; return 1; } @@ -358,14 +379,19 @@ open_stream(int isin, Audio_d *fmt) * subsystem and re-init it via SDL_Init (not * SubSystem, see ensure_sdl_audio for why). Do NOT * call SDL_Quit — that's process-wide and would tear - * down the parent's audio too. + * down the parent's audio too. Skipped when a stream + * is live: quitting under an open stream leaves the + * pointer dangling (use-after-free on the next + * SDL_DestroyAudioStream); failing the open is safe. */ - SDL_QuitSubSystem(SDL_INIT_AUDIO); - sdl_audio_inited = 0; - select_audio_driver(); - if(SDL_Init(SDL_INIT_AUDIO)) { - sdl_audio_inited = 1; - s = SDL_OpenAudioDeviceStream(dev, &spec, NULL, NULL); + if(!sdl_streams_live()) { + SDL_QuitSubSystem(SDL_INIT_AUDIO); + sdl_audio_inited = 0; + select_audio_driver(); + if(SDL_Init(SDL_INIT_AUDIO)) { + sdl_audio_inited = 1; + s = SDL_OpenAudioDeviceStream(dev, &spec, NULL, NULL); + } } } } From 8b0ad762251c72df86ba4f1443e95ccb2689223c Mon Sep 17 00:00:00 2001 From: Teodor Date: Mon, 31 Aug 2026 18:45:29 +0800 Subject: [PATCH 5/6] test(audio): exercise the capture verdict and the read round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 captured to /tmp/audiodev-cap.pcm, but /tmp does not exist inside the emulator namespace: dd failed, cap_bytes stayed 0, and the read always ended 'capture idle', so the active/silent verdicts this feature exists to surface were never verified. Capture to a path under the emu root instead and assert the verdict — 'idle' after a capture to a writable file is now a failure, not a skip. Also add the round-trip property the parser fix enables: the exact line a read of #A/audiodev produced ('in device ') is written back unchanged and must select. --- tests/host/audio_device_select_test.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/host/audio_device_select_test.sh b/tests/host/audio_device_select_test.sh index 7f2397314..b77c9c6d8 100755 --- a/tests/host/audio_device_select_test.sh +++ b/tests/host/audio_device_select_test.sh @@ -53,22 +53,34 @@ run_inferno "bind -a '#A' /dev; echo 'in $dev' > /dev/audiodev; cat /dev/audiode grep -qF "in selected '$dev'" "$log" || fail "selecting '$dev' did not read back" +# 3b. The exact line the readback produced is valid input — the +# round-trip property the docs promise. The read emits +# "in device 'X'"; write it back unchanged and expect the select. +# (rc sh doubles quotes to embed a literal one.) +run_inferno "bind -a '#A' /dev; echo 'in device ''$dev''' > /dev/audiodev; cat /dev/audiodev" >/dev/null +grep -qF "in selected '$dev'" "$log" || + fail "writing back a read line ('in device '$dev'') was refused" + # 4. "default" returns to following the system default. run_inferno "bind -a '#A' /dev; echo 'in $dev' > /dev/audiodev; echo 'in default' > /dev/audiodev; cat /dev/audiodev" >/dev/null grep -q "^in selected default" "$log" || fail "'in default' did not restore the system default" -# 5. A capture reports whether it heard anything. Without a usable -# microphone this can only reach "silent", which is itself the verdict -# the selection exists to surface — so both outcomes pass here, and only -# a missing verdict fails. -run_inferno "bind -a '#A' /dev; echo 'in rate 16000 chans 1 bits 16 enc pcm' > /dev/audioctl; dd -if /dev/audio -of /tmp/audiodev-cap.pcm -bs 16000 -count 8; cat /dev/audiodev" >/dev/null +# 5. A capture reports whether it heard anything. The capture file +# lives under the emu root — /tmp does not exist inside the +# emulator namespace, and a capture written nowhere leaves the +# verdict 'capture idle' forever, testing nothing. With a real +# path the verdict is asserted: 'active' and 'silent' are both +# real answers (a silent-but-authorized room vs a muted or +# virtual device), but 'idle' after a capture to a writable file +# means the diagnostic never ran — that is a failure. +run_inferno "bind -a '#A' /dev; echo 'in rate 16000 chans 1 bits 16 enc pcm' > /dev/audioctl; dd -if /dev/audio -of /$TMPDIR_/audiodev-cap.pcm -bs 16000 -count 8; cat /dev/audiodev" >/dev/null if grep -q "^capture active" "$log"; then echo "capture verdict: active" elif grep -q "^capture silent" "$log"; then echo "SKIP: capture ran but heard only silence (no usable microphone here)" elif grep -q "^capture idle" "$log"; then - echo "SKIP: the capture device delivered no data in this host session" + fail "capture wrote nothing to /$TMPDIR_/audiodev-cap.pcm; no verdict was reported" else fail "no capture verdict reported after a capture" fi From fd51797aa797477bde09dbd386df0f18c860228c Mon Sep 17 00:00:00 2001 From: Teodor Date: Mon, 31 Aug 2026 18:45:29 +0800 Subject: [PATCH 6/6] docs: fix the #A/audiodev round-trip example to valid Inferno sh --- docs/SPEECH-REMOTE-AUDIO.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/SPEECH-REMOTE-AUDIO.md b/docs/SPEECH-REMOTE-AUDIO.md index bed79a626..8c0b83cbd 100644 --- a/docs/SPEECH-REMOTE-AUDIO.md +++ b/docs/SPEECH-REMOTE-AUDIO.md @@ -108,7 +108,9 @@ that is already open is reopened on the new device. ```sh echo 'in Built-in Microphone' > /dev/audiodev -echo "$(cat /dev/audiodev | grep '^in device' | head -1)" > /dev/audiodev +# a line from the read, written back verbatim: +line=`{grep '^in device' /dev/audiodev | sed 1q} +echo $line > /dev/audiodev ``` A launcher that already knows the right device can set it before the