diff --git a/docs/worker.md b/docs/worker.md index 28db89a..48736a6 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -15,8 +15,11 @@ cancellation; this component does not implement focus or delivery policy. The adapter requires an existing absolute, owner-private `$XDG_RUNTIME_DIR` (no symlink at the final component), creates its own 0700 `mkdtemp` directory, and writes only `clip.raw` with `O_EXCL|O_NOFOLLOW`, mode 0600. Clips are -3200..320000 finite float samples, mono 16 kHz, stored as little-endian IEEE -float32 (0.2..20 s). Files are unlinked after replies or shutdown, and the +3200..320000 finite float samples in `[-1, 1]`, mono 16 kHz, stored as +little-endian IEEE float32 (0.2..20 s). The microphone adapter saturates finite +capture/resampler overshoot to this range without rescaling the rest of the +clip; NaN/infinity are rejected. `Worker::submit` independently rejects samples +outside this contract before writing a clip or request. Files are unlinked after replies or shutdown, and the private directory is removed. Private clips are not encrypted against the account owner/root; do not use an untrusted runtime directory. The caller should pass a trusted interpreter and script. By default audio/transcripts are diff --git a/src/audio.cpp b/src/audio.cpp index bdf43e8..e48bbb5 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -47,8 +47,13 @@ void Audio::drain() { if (bytes % sizeof(float)) throw std::runtime_error("Misaligned microphone samples"); if (recording_) { const auto retain = std::min(std::size_t(bytes) / sizeof(float), 320000 - pcm_.size()); - for (std::size_t i = 0; i < retain; ++i) + for (std::size_t i = 0; i < retain; ++i) { if (!std::isfinite(chunk[i])) throw std::runtime_error("Invalid microphone samples"); + // Floating-point capture/resampling can exceed full scale. The + // ASR API requires normalized PCM; saturate peaks, never scale + // the whole clip or silently turn nonfinite input into speech. + chunk[i] = std::clamp(chunk[i], -1.0f, 1.0f); + } pcm_.insert(pcm_.end(), chunk.begin(), chunk.begin() + retain); } last_data_ = std::chrono::steady_clock::now(); diff --git a/src/audio.hpp b/src/audio.hpp index 99288e8..a6e7f85 100644 --- a/src/audio.hpp +++ b/src/audio.hpp @@ -10,7 +10,7 @@ public: void prepare(); // open/resume once, while app is ready; never retain idle samples void start(); // arm the already-running stream without touching the device bool poll(); // drain even while idle; true at 20s limit; throws on loss/stall - std::vector finish(); + std::vector finish(); // finite PCM saturated to [-1, 1] for the ASR API void cancel(); // discard current clip, leave the device running void close(); // release device on shutdown or capture failure bool recording() const { return recording_; } diff --git a/src/worker.cpp b/src/worker.cpp index a2d2e9d..59f3960 100644 --- a/src/worker.cpp +++ b/src/worker.cpp @@ -353,7 +353,9 @@ void Worker::submit(uint64_t id, const std::vector& pcm) { auto& s = *state_; if (!ready() || s.pending) throw std::logic_error("worker not ready or request already pending"); if (pcm.size() < 3200 || pcm.size() > 320000) throw std::invalid_argument("clip must be 0.2..20 seconds at 16 kHz"); - for (float value : pcm) if (!std::isfinite(value)) throw std::invalid_argument("nonfinite PCM sample"); + for (float value : pcm) + if (!std::isfinite(value) || value < -1.0f || value > 1.0f) + throw std::invalid_argument("PCM samples must be finite and in [-1, 1]"); const std::string path = s.dir + "/clip.raw"; int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); if (fd < 0) throw std::runtime_error("cannot create exclusive private clip"); diff --git a/tests/audio_test.cpp b/tests/audio_test.cpp index a415107..a28a832 100644 --- a/tests/audio_test.cpp +++ b/tests/audio_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace { @@ -52,6 +53,31 @@ int main() { feed(0.9f, 100); audio.start(); feed(0.4f, 3200); clip = audio.finish(); assert(clip.size() == 3200 && clip.front() == 0.4f); assert(opens == 1 && closes == 0 && resumes == 1); + // Real float capture can exceed full scale. Both poll and finish must + // saturate finite peaks without changing ordinary samples or clip size. + audio.start(); + feed(1.01f, 1600); feed(-1.25f, 1600); audio.poll(); + feed(std::numeric_limits::max(), 1); + feed(-std::numeric_limits::max(), 1); + feed(1.f, 1); feed(-1.f, 1); feed(.25f, 1); feed(0.f, 1); + clip = audio.finish(); + assert(clip.size() == 3206); + assert(std::all_of(clip.begin(), clip.begin() + 1600, [](float x) { return x == 1.f; })); + assert(std::all_of(clip.begin() + 1600, clip.begin() + 3200, [](float x) { return x == -1.f; })); + assert(clip[3200] == 1.f && clip[3201] == -1.f && clip[3202] == 1.f && clip[3203] == -1.f); + assert(clip[3204] == .25f && clip[3205] == 0.f); + for (float invalid : {std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + audio.start(); feed(.2f, 3200); feed(invalid, 1); + bool rejected = false; + try { (void)audio.finish(); } catch (const std::runtime_error&) { rejected = true; } + assert(rejected && !audio.recording() && current->queued.empty()); + audio.start(); feed(.3f, 3200); + clip = audio.finish(); + assert(clip.size() == 3200 && clip.front() == .3f); // no failed clip tail leaks + } + assert(opens == 1 && closes == 0 && resumes == 1); removed = true; bool failed = false; try { audio.poll(); } catch (const std::runtime_error&) { failed = true; } diff --git a/tests/worker_test.cpp b/tests/worker_test.cpp index d09cf3f..81e8f72 100644 --- a/tests/worker_test.cpp +++ b/tests/worker_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,15 @@ int main(int argc, char** argv) { try { worker.submit(1, std::vector(3199)); } catch (const std::invalid_argument&) { rejected = true; } assert(rejected); + for (float value : {1.01f, -1.01f, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + auto invalid = clip; invalid.back() = value; + rejected = false; + try { worker.submit(99, invalid); } + catch (const std::invalid_argument&) { rejected = true; } + assert(rejected && worker.ready()); // rejection happens before clip/IPC mutation + } + clip.front() = -1.f; clip.back() = 1.f; // full-scale boundaries are valid worker.submit(100, clip); rejected = false; try { worker.submit(101, clip); }