diff --git a/docs/SPEECH-REMOTE-AUDIO.md b/docs/SPEECH-REMOTE-AUDIO.md index 4eab8b98c..8c0b83cbd 100644 --- a/docs/SPEECH-REMOTE-AUDIO.md +++ b/docs/SPEECH-REMOTE-AUDIO.md @@ -75,6 +75,67 @@ 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 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 +# 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 +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 +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..7ac8fe724 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 /* @@ -96,35 +137,32 @@ 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; 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 +184,58 @@ 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 +sdl_streams_live(void) +{ + return in_stream != NULL || out_stream != NULL; +} + 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 — 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(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; + } /* Configure AVAudioSession (or platform equivalent) before SDL * touches CoreAudio — no-op on macOS/Linux desktop, real impl on * iOS. INFR-186. */ @@ -165,25 +250,109 @@ 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; + if(!audio_devices_present()) + sdl_quiet_host = 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; @@ -210,21 +379,26 @@ open_stream(SDL_AudioDeviceID dev, 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; - SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "coreaudio"); - 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); + } } } } 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 +406,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 +554,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 +569,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 +637,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 +703,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 +725,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 +733,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 +808,10 @@ void audio_file_init(void) { audio_info_init(&av); + audio_bufcaps_register(audio_get_buffer_ms); } + void audio_ctl_init(void) { @@ -540,10 +846,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/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..5b947128b 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,88 @@ 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 + * 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) +{ + 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++; + /* 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 == '\''){ + 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 +208,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 +427,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..b77c9c6d8 --- /dev/null +++ b/tests/host/audio_device_select_test.sh @@ -0,0 +1,89 @@ +#!/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" + +# 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. 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 + fail "capture wrote nothing to /$TMPDIR_/audiodev-cap.pcm; no verdict was reported" +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..4144b4fc8 --- /dev/null +++ b/tests/host/audio_macos_test.sh @@ -0,0 +1,89 @@ +#!/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 + return 0 + fi + cat "$log" + assert_devices_or_skip "$log" || return 1 + 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 [ ! -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. 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 + "$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