#include "cloud.h" #include "app_state.h" #include "audio.h" #include "config.h" #include "secrets.h" #include #include #include #include #include #include #include #include #include static volatile bool busy = false; static char jobWav[64]; bool cloudBusy() { return busy; } // Network diagnostics on the serial port, e.g. "[net 12.345s heap 98304] TLS connect ok" #define NETLOG(fmt, ...) \ Serial.printf("[net %lu.%03lus heap %lu] " fmt "\n", millis() / 1000, millis() % 1000, \ (unsigned long)ESP.getFreeHeap(), ##__VA_ARGS__) static const char *wifiStatusName(wl_status_t s) { switch (s) { case WL_CONNECTED: return "connected"; case WL_NO_SSID_AVAIL: return "SSID not found"; case WL_CONNECT_FAILED: return "connect failed (wrong password?)"; case WL_CONNECTION_LOST: return "connection lost"; case WL_DISCONNECTED: return "disconnected"; case WL_IDLE_STATUS: return "idle"; default: return "unknown"; } } static void logTlsError(NetworkClientSecure &c, const char *what) { char msg[128] = ""; int code = c.lastError(msg, sizeof(msg)); NETLOG("%s: TLS error %d (-0x%04X) %s", what, code, code < 0 ? -code : code, msg); } // --------------------------------------------------------------------------- // Minimal HTTP/1.1 over TLS. We talk to the socket directly (rather than // HTTPClient) so we can stream a multi-MB WAV from the SD card, wait minutes // for a reply, and decode chunked / server-sent-event responses line by line. // --------------------------------------------------------------------------- static int readByteWait(NetworkClient &c, uint32_t timeoutMs) { uint32_t t0 = millis(); while (!c.available()) { if (!c.connected() || millis() - t0 > timeoutMs) return -1; delay(5); } return c.read(); } static bool readRawLine(NetworkClient &c, String &line, uint32_t timeoutMs) { line = ""; for (;;) { int b = readByteWait(c, timeoutMs); if (b < 0) return line.length() > 0; if (b == '\n') break; if (b != '\r') line += (char)b; } return true; } static bool writeAll(NetworkClient &c, const uint8_t *data, size_t len) { uint32_t t0 = millis(); while (len) { size_t n = c.write(data, len); if (n == 0) { if (!c.connected()) { NETLOG("write failed: server closed the connection (%u bytes unsent)", (unsigned)len); return false; } if (millis() - t0 > 20000) { NETLOG("write failed: no progress for 20 s (%u bytes unsent, Wi-Fi %s, RSSI %d dBm)", (unsigned)len, wifiStatusName(WiFi.status()), WiFi.RSSI()); return false; } delay(10); continue; } data += n; len -= n; t0 = millis(); } return true; } static bool writeAll(NetworkClient &c, const String &s) { return writeAll(c, (const uint8_t *)s.c_str(), s.length()); } struct HttpHead { int status = 0; bool chunked = false; long contentLength = -1; }; // Wait (up to firstByteTimeoutMs) for the server to answer, then parse the head. static bool readHead(NetworkClient &c, HttpHead &h, uint32_t firstByteTimeoutMs) { uint32_t t0 = millis(); while (!c.available()) { if (!c.connected()) { NETLOG("no response: server closed the connection after %lu ms", millis() - t0); return false; } if (millis() - t0 > firstByteTimeoutMs) { NETLOG("no response: timed out after %lu s", firstByteTimeoutMs / 1000); return false; } delay(20); } NETLOG("first response byte after %lu ms", millis() - t0); String line; if (!readRawLine(c, line, 15000)) { NETLOG("no response: status line never arrived"); return false; } NETLOG("< %s", line.c_str()); int sp = line.indexOf(' '); if (sp < 0) return false; h.status = line.substring(sp + 1).toInt(); while (readRawLine(c, line, 15000) && line.length()) { if (h.status >= 300) NETLOG("< %s", line.c_str()); // full headers only when something went wrong String lower = line; lower.toLowerCase(); if (lower.startsWith("transfer-encoding:") && lower.indexOf("chunked") > 0) h.chunked = true; if (lower.startsWith("content-length:")) h.contentLength = lower.substring(15).toInt(); } return true; } // Reads a response body, transparently removing chunked transfer encoding. class BodyReader { public: BodyReader(NetworkClient &c, const HttpHead &h, uint32_t idleTimeoutMs) : c_(c), chunked_(h.chunked), remaining_(h.contentLength), timeout_(idleTimeoutMs) {} // Returns bytes read, 0 at end of body (or on timeout / disconnect). size_t read(uint8_t *buf, size_t n) { if (done_) return 0; if (chunked_) { if (chunkLeft_ == 0) { String sizeLine; if (!readRawLine(c_, sizeLine, timeout_)) return finish(); chunkLeft_ = strtol(sizeLine.c_str(), nullptr, 16); if (chunkLeft_ == 0) return finish(); } if (n > chunkLeft_) n = chunkLeft_; } else if (remaining_ >= 0) { if (remaining_ == 0) return finish(); if ((long)n > remaining_) n = remaining_; } size_t got = 0; uint32_t t0 = millis(); while (got == 0) { int avail = c_.available(); if (avail > 0) { int r = c_.read(buf, min((size_t)avail, n)); if (r > 0) got = r; } else if (!c_.connected() || millis() - t0 > timeout_) { return finish(); } else { delay(5); } } if (chunked_) { chunkLeft_ -= got; if (chunkLeft_ == 0) { String crlf; readRawLine(c_, crlf, timeout_); } } else if (remaining_ > 0) { remaining_ -= got; } return got; } bool readLine(String &line) { line = ""; for (;;) { if (pos_ == len_) { len_ = read(buf_, sizeof(buf_)); pos_ = 0; if (len_ == 0) return line.length() > 0; } char ch = (char)buf_[pos_++]; if (ch == '\n') return true; if (ch != '\r') line += ch; } } String readAll(size_t maxLen) { String s; uint8_t b[256]; size_t n; while (s.length() < maxLen && (n = read(b, sizeof(b))) > 0) s.concat((const char *)b, n); return s; } private: size_t finish() { done_ = true; return 0; } NetworkClient &c_; bool chunked_; long remaining_; uint32_t timeout_; size_t chunkLeft_ = 0; bool done_ = false; uint8_t buf_[512]; size_t pos_ = 0, len_ = 0; }; static bool connectTls(NetworkClientSecure &c, const char *host) { c.useBuiltinCACertBundle(); // verify the server certificate c.setHandshakeTimeout(30); NETLOG("connecting to %s:443", host); uint32_t t0 = millis(); if (!c.connect(host, 443)) { // does its own (thread-safe) DNS lookup logTlsError(c, "connect failed (DNS or TLS)"); return false; } NETLOG("TLS connect ok to %s in %lu ms", c.remoteIP().toString().c_str(), millis() - t0); return true; } static bool ensureWifi() { if (WiFi.status() != WL_CONNECTED) { NETLOG("Wi-Fi %s, reconnecting...", wifiStatusName(WiFi.status())); WiFi.reconnect(); for (int i = 0; i < 150 && WiFi.status() != WL_CONNECTED; i++) delay(100); } if (WiFi.status() != WL_CONNECTED) { NETLOG("Wi-Fi still %s", wifiStatusName(WiFi.status())); return false; } NETLOG("Wi-Fi ok: SSID \"%s\", IP %s, DNS %s, RSSI %d dBm", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), WiFi.dnsIP().toString().c_str(), WiFi.RSSI()); return true; } // --------------------------------------------------------------------------- // SD reads. On this board the card fails multi-sector reads (CMD18) with an // I/O error, while single-sector reads (CMD17) and all writes work. Arduino's // File::read() goes through stdio, which reads ahead 4 KB at a time and so // always triggers multi-sector reads. Calling POSIX read() with 512-byte // requests keeps every read to a single sector. // --------------------------------------------------------------------------- static int sdOpenRead(const char *path) { return open((String("/sd") + path).c_str(), O_RDONLY); } // Fills buf with up to len bytes, 512 at a time. Returns bytes read, or -1 on error. static int sdRead(int fd, uint8_t *buf, size_t len) { size_t got = 0; while (got < len) { int n = read(fd, buf + got, min((size_t)512, len - got)); if (n < 0) return got ? (int)got : -1; if (n == 0) break; got += n; } return got; } static String httpErrorText(const char *service, int status, const String &body) { // Both OpenAI and Anthropic return {"error": {"message": "..."}}. NETLOG("%s HTTP %d, body: %s", service, status, body.c_str()); JsonDocument doc; String msg; if (deserializeJson(doc, body) == DeserializationError::Ok) msg = doc["error"]["message"] | ""; if (msg.isEmpty()) msg = body.substring(0, 120); return String(service) + " HTTP " + status + ": " + msg; } // --------------------------------------------------------------------------- // Step 1: speech-to-text, in parts. // // Long recordings are split into ~10-minute parts (each upload must stay // under 25 MB). Each part is sent as its own small WAV: a fresh 44-byte // header followed by a slice of the recording's PCM, streamed from the card. // Cuts are moved to the quietest moment just before the target, so no word is // split. Text from the end of the previous part goes along as a "prompt" so // the speech-to-text model keeps names and spelling consistent across parts. // // After each part, its text is appended to the transcript and the position is // saved to a .progress file. If a part fails (Wi-Fi drop, API error), holding // PLUS again resumes from that part instead of starting over. // --------------------------------------------------------------------------- static const uint32_t BYTES_PER_SEC = SAMPLE_RATE * 2; static const uint32_t PART_BYTES = STT_SEGMENT_SECONDS * BYTES_PER_SEC; static const uint32_t LAST_PART_SLACK = PART_BYTES / 10; // fold a short tail (<1 min) into the last part static const int CUT_BLOCKS = min(CUT_SEARCH_SECONDS, STT_SEGMENT_SECONDS / 4) * 10; // 0.1 s blocks searched static_assert(44 + PART_BYTES + LAST_PART_SLACK < MAX_UPLOAD_BYTES, "parts must stay under the upload limit"); static String hms(uint32_t secs) { char b[16]; snprintf(b, sizeof(b), "%02lu:%02lu:%02lu", secs / 3600, secs / 60 % 60, secs % 60); return b; } // Last maxBytes of s, starting on a UTF-8 character boundary. static String utf8Tail(const String &s, size_t maxBytes) { if (s.length() <= maxBytes) return s; size_t i = s.length() - maxBytes; while (i < s.length() && ((uint8_t)s[i] & 0xC0) == 0x80) i++; return s.substring(i); } // Pick a cut point (file offset) in the CUT_SEARCH_SECONDS before `target`: // the middle of the quietest 0.4 s, measured in 0.1 s blocks. static uint32_t findQuietCut(int fd, uint32_t target) { const uint32_t BLOCK = BYTES_PER_SEC / 10; const int NB = CUT_BLOCKS; const uint32_t start = target - NB * BLOCK; static uint64_t energy[CUT_BLOCKS]; static int16_t samples[BYTES_PER_SEC / 20]; // BLOCK bytes { SpiLock lock; if (lseek(fd, start, SEEK_SET) < 0) return target; for (int b = 0; b < NB; b++) { if (sdRead(fd, (uint8_t *)samples, BLOCK) != (int)BLOCK) return target; uint64_t e = 0; for (uint32_t i = 0; i < BLOCK / 2; i++) e += abs(samples[i]); energy[b] = e; } } int best = 0; uint64_t bestSum = UINT64_MAX; for (int b = 0; b + 4 <= NB; b++) { uint64_t sum = energy[b] + energy[b + 1] + energy[b + 2] + energy[b + 3]; if (sum < bestSum) { bestSum = sum; best = b; } } return start + (best + 2) * BLOCK; // even offset: always on a sample boundary } // Upload bytes [start, end) of the open WAV as one file; the text goes to `text`. static bool transcribePart(int fd, uint32_t start, uint32_t end, const String &fileName, const String &prompt, String &text, String &err) { const uint32_t len = end - start; const char *boundary = "----VoiceNotesFormBoundary7MA4YWxkTrZu0gW"; String pre; pre += String("--") + boundary + "\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n" STT_MODEL "\r\n"; pre += String("--") + boundary + "\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\ntext\r\n"; if (!prompt.isEmpty()) pre += String("--") + boundary + "\r\nContent-Disposition: form-data; name=\"prompt\"\r\n\r\n" + prompt + "\r\n"; pre += String("--") + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\nContent-Type: audio/wav\r\n\r\n"; String post = String("\r\n--") + boundary + "--\r\n"; uint8_t header[44]; wavHeader(header, len); const size_t bodyLen = pre.length() + sizeof(header) + len + post.length(); NetworkClientSecure client; if (!connectTls(client, STT_HOST)) { err = "Can't reach " STT_HOST; return false; } String req; req += "POST " STT_PATH " HTTP/1.1\r\n"; req += "Host: " STT_HOST "\r\n"; req += "Authorization: Bearer " STT_API_KEY "\r\n"; req += String("Content-Type: multipart/form-data; boundary=") + boundary + "\r\n"; req += "Content-Length: " + String(bodyLen) + "\r\n"; req += "Connection: close\r\n\r\n"; NETLOG("STT: POST %s (%s, %u bytes) model=" STT_MODEL ", key %.7s...", fileName.c_str(), hms(len / BYTES_PER_SEC).c_str(), (unsigned)bodyLen, STT_API_KEY); bool ok = writeAll(client, req) && writeAll(client, pre) && writeAll(client, header, sizeof(header)); if (!ok) NETLOG("STT: failed while sending request headers"); { SpiLock lock; if (ok && lseek(fd, start, SEEK_SET) < 0) { NETLOG("STT: seek failed"); ok = false; } } static uint8_t buf[4096]; uint32_t sent = 0, upStart = millis(); uint8_t lastLogged = 0; app.progress = 0; while (ok && sent < len) { int n; { SpiLock lock; n = sdRead(fd, buf, min((uint32_t)sizeof(buf), len - sent)); } if (n <= 0) { NETLOG("STT: SD read failed at offset %u (errno %d %s)", (unsigned)(start + sent), errno, strerror(errno)); ok = false; break; } ok = writeAll(client, buf, n); if (!ok) NETLOG("STT: upload stopped at byte %u of %u", (unsigned)sent, (unsigned)len); sent += n; app.progress = (uint8_t)((uint64_t)sent * 100 / len); if (app.progress >= lastLogged + 25) { lastLogged = app.progress - app.progress % 25; float secs = (millis() - upStart) / 1000.0f; NETLOG("STT: uploaded %u%% (%.0f KB/s)", app.progress, sent / 1024.0f / max(secs, 0.001f)); } } if (ok) ok = writeAll(client, post); if (!ok) { // Servers often reject early (bad key, file too big) and close mid-upload; // if they sent a reply before closing, show it. err = "Upload failed"; HttpHead early; if (readHead(client, early, 3000)) { BodyReader b(client, early, 3000); err = httpErrorText("STT", early.status, b.readAll(600)); } logTlsError(client, "STT upload"); return false; } NETLOG("STT: upload done in %.1f s, waiting for transcript", (millis() - upStart) / 1000.0f); setState(ST_TRANSCRIBING); HttpHead head; if (!readHead(client, head, 5 * 60 * 1000)) { err = "No reply from STT service"; return false; } BodyReader body(client, head, 60000); if (head.status != 200) { err = httpErrorText("STT", head.status, body.readAll(600)); return false; } text = body.readAll(256 * 1024); // ~10 min of speech is ~10 KB of text text.trim(); NETLOG("STT: got %u bytes of text", text.length()); return true; } static bool readProgress(const String &path, uint32_t &offset, uint8_t &part) { SpiLock lock; File f = SD.open(path, FILE_READ); // tiny file: a single-sector read is fine if (!f) return false; String s = f.readString(); f.close(); return sscanf(s.c_str(), "%lu %hhu", &offset, &part) == 2; } static void writeProgress(const String &path, uint32_t offset, uint8_t part) { SpiLock lock; File f = SD.open(path, FILE_WRITE); if (f) { f.printf("%lu %u\n", offset, part); f.close(); } } // The last ~200 bytes of the transcript so far, used as the prompt when resuming. static String transcriptTail(const String &txtPath) { SpiLock lock; int fd = sdOpenRead(txtPath.c_str()); if (fd < 0) return ""; struct stat st; String s; if (fstat(fd, &st) == 0 && st.st_size > 0) { char b[257]; lseek(fd, st.st_size > 256 ? st.st_size - 256 : 0, SEEK_SET); int n = sdRead(fd, (uint8_t *)b, 256); if (n > 0) s.concat(b, n); } close(fd); return utf8Tail(s, 200); } static bool transcribe(const char *wavPath, const String &txtPath, const String &progPath, String &err) { int fd; uint32_t size; { SpiLock lock; fd = sdOpenRead(wavPath); struct stat st; if (fd < 0 || fstat(fd, &st) != 0) { NETLOG("STT: can't open %s (errno %d %s)", wavPath, errno, strerror(errno)); if (fd >= 0) close(fd); err = "Can't open recording"; return false; } size = st.st_size; } NETLOG("STT: %s, %u bytes (%s of audio)", wavPath, (unsigned)size, hms((size - 44) / BYTES_PER_SEC).c_str()); if (size < 44 + BYTES_PER_SEC / 2) { SpiLock l; close(fd); err = "Recording too short"; return false; } uint32_t offset = 44; uint8_t part = 0; String prompt; if (readProgress(progPath, offset, part)) { NETLOG("STT: resuming at part %u (%s into the recording)", part + 1, hms((offset - 44) / BYTES_PER_SEC).c_str()); prompt = transcriptTail(txtPath); } else { SpiLock lock; SD.remove(txtPath); } if (offset == 44) writeProgress(progPath, offset, part); String base = String(strrchr(wavPath, '/') + 1); base.replace(".wav", ""); bool ok = true; while (ok && offset < size) { uint32_t end = size - offset <= PART_BYTES + LAST_PART_SLACK ? size : findQuietCut(fd, offset + PART_BYTES); app.partIndex = part + 1; app.partCount = part + 1 + (size > end ? (size - end + PART_BYTES - 1) / PART_BYTES : 0); setState(ST_UPLOADING); NETLOG("STT: part %u of ~%u: %s - %s", app.partIndex, app.partCount, hms((offset - 44) / BYTES_PER_SEC).c_str(), hms((end - 44) / BYTES_PER_SEC).c_str()); String text; ok = false; for (int attempt = 1; attempt <= STT_MAX_ATTEMPTS && !ok; attempt++) { if (attempt > 1) { NETLOG("STT: retrying part %u (attempt %d of %d) after: %s", part + 1, attempt, STT_MAX_ATTEMPTS, err.c_str()); delay(5000 * (attempt - 1)); setState(ST_UPLOADING); } if (!ensureWifi()) { err = "Wi-Fi not connected"; continue; } ok = transcribePart(fd, offset, end, base + "_part" + (part + 1) + ".wav", prompt, text, err); } if (!ok) break; if (!text.isEmpty()) { SpiLock lock; File f = SD.open(txtPath, FILE_APPEND); if (!f) { err = "Can't write transcript"; ok = false; break; } f.printf("[%s]\n", hms((offset - 44) / BYTES_PER_SEC).c_str()); f.print(text); f.print("\n\n"); f.close(); prompt = utf8Tail(text, 200); } app.received += text.length(); offset = end; part++; writeProgress(progPath, offset, part); } { SpiLock l; close(fd); } if (!ok) { err = "Part " + String(app.partIndex) + "/" + String(app.partCount) + ": " + err + ". Hold PLUS to resume"; return false; } bool haveText; { SpiLock lock; SD.remove(progPath); haveText = SD.exists(txtPath); } if (!haveText) { err = "No speech detected"; return false; } NETLOG("STT: transcript complete -> %s", txtPath.c_str()); return true; } // --------------------------------------------------------------------------- // Step 2: summarize with Claude. // // A two-hour transcript is ~100 KB, too big to hold in RAM next to Wi-Fi and // TLS. So the JSON request body is streamed: the transcript is read from the // card twice, once to measure its JSON-escaped length for Content-Length, // then again to escape and send it in 512-byte pieces. The reply is streamed // too (server-sent events), and each text delta is appended to the notes file. // --------------------------------------------------------------------------- static size_t jsonEscape(const uint8_t *in, size_t n, char *out) { size_t o = 0; for (size_t i = 0; i < n; i++) { uint8_t ch = in[i]; switch (ch) { case '"': if (out) { out[o] = '\\'; out[o + 1] = '"'; } o += 2; break; case '\\': if (out) { out[o] = '\\'; out[o + 1] = '\\'; } o += 2; break; case '\n': if (out) { out[o] = '\\'; out[o + 1] = 'n'; } o += 2; break; case '\r': if (out) { out[o] = '\\'; out[o + 1] = 'r'; } o += 2; break; case '\t': if (out) { out[o] = '\\'; out[o + 1] = 't'; } o += 2; break; default: if (ch < 0x20) { if (out) snprintf(out + o, 7, "\\u%04x", ch); o += 6; } else { if (out) out[o] = ch; // UTF-8 bytes pass through unchanged o += 1; } } } return o; // with out == nullptr, just measures } static String jsonEscape(const String &s) { String out; out.reserve(s.length() + s.length() / 8 + 8); char tmp[6 * 64 + 1]; for (size_t i = 0; i < s.length(); i += 64) { size_t n = jsonEscape((const uint8_t *)s.c_str() + i, min((size_t)64, s.length() - i), tmp); out.concat(tmp, n); } return out; } static const char *SYSTEM_PROMPT = "You turn recordings of meetings and voice memos into clear, well-organized notes. The transcript " "comes from automatic speech recognition, so expect filler words, false starts and the occasional " "misheard word; infer the intended meaning, but don't add facts that aren't in the recording."; static const char *NOTES_INSTRUCTIONS = "Write notes in Markdown, scaled to the length of the recording (a short memo needs only a few lines):\n" "- a short descriptive title as a level-1 heading\n" "- **Summary**: one short paragraph, or up to three for a long meeting\n" "- **Participants**: people who are named or clearly identifiable (omit if none)\n" "- **Decisions**: what was decided (omit if nothing was)\n" "- **Action items**: checkbox bullets with owner and due date when mentioned, or \"None\"\n" "- **Discussion**: for longer recordings, the main topics in order, each with its key points and the " "[hh:mm:ss] time it came up\n" "- **Open questions**: unresolved issues (omit if none)\n" "The transcript has no speaker labels, so only attribute a statement to someone when the transcript " "makes it clear who said it. Reply with the notes only."; static bool summarize(const String &txtPath, const String &mdPath, const char *wavPath, String &err) { // Pass 1: measure the transcript and its escaped length. static uint8_t in[512]; static char out[512 * 6]; size_t rawLen = 0, escLen = 0; { SpiLock lock; int fd = sdOpenRead(txtPath.c_str()); if (fd < 0) { err = "Can't open transcript"; return false; } int n; while ((n = sdRead(fd, in, sizeof(in))) > 0) { rawLen += n; escLen += jsonEscape(in, n, nullptr); } close(fd); } NETLOG("Claude: transcript %u bytes", (unsigned)rawLen); if (rawLen == 0) { err = "Transcript is empty"; return false; } // The file name carries the start time, e.g. REC_2026-10-02_14-30-05. String stamp = String(strrchr(wavPath, '/') + 1); stamp.replace("REC_", ""); stamp.replace(".wav", ""); uint32_t secs = 0; { SpiLock lock; File w = SD.open(wavPath, FILE_READ); if (w) { secs = (w.size() - 44) / BYTES_PER_SEC; w.close(); } } String intro = "Below is the transcript of a recording started " + stamp + " and lasting " + hms(secs) + ". It was transcribed in parts; [hh:mm:ss] markers give the time since the recording " "began.\n\n\n"; String outro = String("\n\n\n") + NOTES_INSTRUCTIONS; String prefix; prefix += "{\"model\":\"" CLAUDE_MODEL "\",\"max_tokens\":64000,\"stream\":true,"; prefix += "\"fallbacks\":\"default\",\"output_config\":{\"effort\":\"medium\"},"; prefix += "\"system\":\"" + jsonEscape(SYSTEM_PROMPT) + "\","; prefix += "\"messages\":[{\"role\":\"user\",\"content\":\"" + jsonEscape(intro); String suffix = jsonEscape(outro) + "\"}]}"; const size_t bodyLen = prefix.length() + escLen + suffix.length(); NetworkClientSecure client; if (!connectTls(client, "api.anthropic.com")) { err = "Can't reach Claude API"; return false; } String req; req += "POST /v1/messages HTTP/1.1\r\n"; req += "Host: api.anthropic.com\r\n"; req += "x-api-key: " ANTHROPIC_API_KEY "\r\n"; req += "anthropic-version: 2023-06-01\r\n"; req += "anthropic-beta: server-side-fallback-2026-07-01\r\n"; req += "content-type: application/json\r\n"; req += "accept: text/event-stream\r\n"; req += "Content-Length: " + String(bodyLen) + "\r\n"; req += "Connection: close\r\n\r\n"; NETLOG("Claude: POST /v1/messages model=" CLAUDE_MODEL ", key %.7s..., body %u bytes", ANTHROPIC_API_KEY, (unsigned)bodyLen); // Pass 2: send prefix, escaped transcript, suffix. bool ok = writeAll(client, req) && writeAll(client, prefix); int fd; { SpiLock lock; fd = sdOpenRead(txtPath.c_str()); } size_t sentEsc = 0; while (ok && fd >= 0) { int n; { SpiLock lock; n = sdRead(fd, in, sizeof(in)); } if (n <= 0) break; size_t m = jsonEscape(in, n, out); ok = writeAll(client, (const uint8_t *)out, m); sentEsc += m; } if (fd >= 0) { SpiLock l; close(fd); } if (ok && sentEsc != escLen) { NETLOG("Claude: transcript changed while sending (%u vs %u bytes)", (unsigned)sentEsc, (unsigned)escLen); ok = false; } if (ok) ok = writeAll(client, suffix); if (!ok) { logTlsError(client, "Claude send"); err = "Send to Claude failed"; return false; } HttpHead head; if (!readHead(client, head, 120000)) { err = "No reply from Claude"; return false; } // Ping events arrive every few seconds while the model thinks, so a 90 s // idle timeout only trips if the connection has really stalled. BodyReader sse(client, head, 90000); if (head.status != 200) { err = httpErrorText("Claude", head.status, sse.readAll(600)); return false; } File md; { SpiLock lock; md = SD.open(mdPath, FILE_WRITE); if (!md) { err = "Can't write notes"; return false; } } // Keep only the fields we use from each event; thinking signatures etc. are dropped. JsonDocument filter; filter["type"] = true; filter["delta"]["type"] = true; filter["delta"]["text"] = true; filter["delta"]["stop_reason"] = true; filter["error"]["message"] = true; String line, pending, stopReason; bool gotStop = false; size_t total = 0; auto flush = [&]() { if (pending.isEmpty()) return; SpiLock lock; md.print(pending); pending = ""; }; while (sse.readLine(line)) { if (!line.startsWith("data:")) continue; JsonDocument ev; if (deserializeJson(ev, line.c_str() + 5, DeserializationOption::Filter(filter))) continue; const char *type = ev["type"] | ""; if (!strcmp(type, "content_block_delta") && !strcmp(ev["delta"]["type"] | "", "text_delta")) { const char *t = ev["delta"]["text"] | ""; pending += t; total += strlen(t); app.received = total; if (pending.length() > 512) flush(); } else if (!strcmp(type, "message_delta")) { stopReason = ev["delta"]["stop_reason"] | ""; } else if (!strcmp(type, "message_stop")) { gotStop = true; break; } else if (!strcmp(type, "error")) { NETLOG("Claude stream error event: %s", line.c_str()); err = String("Claude: ") + (ev["error"]["message"] | "stream error"); break; } } flush(); NETLOG("Claude: stream ended, %u chars, stop_reason=%s, message_stop=%s", (unsigned)total, stopReason.isEmpty() ? "(none)" : stopReason.c_str(), gotStop ? "yes" : "no"); { SpiLock lock; md.printf("\n\n---\n_Recording: %s (%s) ยท Transcript: %s_\n", wavPath, hms(secs).c_str(), txtPath.c_str()); md.close(); } if (!err.isEmpty()) return false; if (stopReason == "refusal") { err = "Claude declined this request"; return false; } if (!gotStop) { err = "Claude stream cut off"; return false; } if (total == 0) { err = "Claude returned no text"; return false; } return true; } // --------------------------------------------------------------------------- // Step 3: copy the notes to Nextcloud over WebDAV (HTTP PUT with Basic auth). // /remote.php/webdav/ maps to the logged-in user's files, so it works whether // NEXTCLOUD_USER is the user ID or an email login. // --------------------------------------------------------------------------- #ifndef NEXTCLOUD_HOST #define NEXTCLOUD_HOST "" #define NEXTCLOUD_USER "" #define NEXTCLOUD_PASSWORD "" #define NEXTCLOUD_FOLDER "" #endif static bool nextcloudEnabled() { return strlen(NEXTCLOUD_HOST) > 0; } static String urlEncodePath(const String &s) { String out; for (size_t i = 0; i < s.length(); i++) { char c = s[i]; if (isalnum((unsigned char)c) || strchr("-_.~/", c)) { out += c; } else { char b[4]; snprintf(b, sizeof(b), "%%%02X", (uint8_t)c); out += b; } } return out; } // One WebDAV request. Streams the local file as the body when localPath is set. static int webdavRequest(const char *method, const String &remotePath, const char *localPath, const char *contentType, String &err) { int fd = -1; size_t len = 0; if (localPath) { SpiLock lock; fd = sdOpenRead(localPath); struct stat st; if (fd < 0 || fstat(fd, &st) != 0) { if (fd >= 0) close(fd); err = String("Can't open ") + localPath; return -1; } len = st.st_size; } NetworkClientSecure client; if (!connectTls(client, NEXTCLOUD_HOST)) { if (fd >= 0) { SpiLock l; close(fd); } err = "Can't reach " NEXTCLOUD_HOST; return -1; } String req = String(method) + " /remote.php/webdav/" + urlEncodePath(remotePath) + " HTTP/1.1\r\n"; req += "Host: " NEXTCLOUD_HOST "\r\n"; req += "Authorization: Basic " + base64::encode(String(NEXTCLOUD_USER ":" NEXTCLOUD_PASSWORD)) + "\r\n"; if (contentType) req += String("Content-Type: ") + contentType + "\r\n"; req += "Content-Length: " + String(len) + "\r\n"; req += "Connection: close\r\n\r\n"; NETLOG("Nextcloud: %s /remote.php/webdav/%s (%u bytes) as " NEXTCLOUD_USER, method, remotePath.c_str(), (unsigned)len); bool ok = writeAll(client, req); static uint8_t buf[4096]; size_t sent = 0; while (ok && fd >= 0 && sent < len) { int n; { SpiLock lock; n = sdRead(fd, buf, sizeof(buf)); } if (n <= 0) { ok = false; break; } ok = writeAll(client, buf, n); sent += n; } if (fd >= 0) { SpiLock l; close(fd); } if (!ok) { err = "Upload to Nextcloud failed"; return -1; } HttpHead head; if (!readHead(client, head, 60000)) { err = "No reply from Nextcloud"; return -1; } if (head.status >= 300) { BodyReader body(client, head, 5000); NETLOG("Nextcloud: HTTP %d, body: %s", head.status, body.readAll(400).c_str()); } return head.status; } static bool nextcloudUpload(const String &localPath, const char *contentType, String &err) { String name = String(strrchr(localPath.c_str(), '/') + 1); String remote = String(NEXTCLOUD_FOLDER) + "/" + name; int status = webdavRequest("PUT", remote, localPath.c_str(), contentType, err); if (status == 404 || status == 409) { // folder missing: create it, then retry once NETLOG("Nextcloud: folder " NEXTCLOUD_FOLDER " not found, creating it"); int mk = webdavRequest("MKCOL", NEXTCLOUD_FOLDER, nullptr, nullptr, err); if (mk == 201 || mk == 405) status = webdavRequest("PUT", remote, localPath.c_str(), contentType, err); } if (status == 201 || status == 204) { NETLOG("Nextcloud: saved %s", remote.c_str()); return true; } if (status == 401) err = "Nextcloud login failed - check the app password in secrets.h"; else if (status == 507) err = "Nextcloud storage is full"; else if (status > 0) err = "Nextcloud HTTP " + String(status); return false; } // --------------------------------------------------------------------------- static void netTask(void *) { String base = String(strrchr(jobWav, '/') + 1); base.replace(".wav", ""); String txtPath = String(DIR_NOTES "/") + base + "_transcript.txt"; String progPath = String(DIR_NOTES "/") + base + "_transcript.progress"; String mdPath = String(DIR_NOTES "/") + base + "_summary.md"; String err; // Reuse finished work: a transcript without a .progress file is complete, // and an existing summary only needs copying to Nextcloud (e.g. when only // that step failed last time). Delete the summary on the card to redo it. bool transcriptDone, summaryDone; { SpiLock lock; transcriptDone = SD.exists(txtPath) && !SD.exists(progPath); summaryDone = transcriptDone && SD.exists(mdPath); } app.progress = 0; app.received = 0; app.partIndex = 0; app.partCount = 0; setState(ST_UPLOADING, base.c_str()); bool ok = ensureWifi(); if (!ok) err = "Wi-Fi not connected"; if (ok && !transcriptDone) ok = transcribe(jobWav, txtPath, progPath, err); if (ok && !summaryDone) { app.received = 0; setState(ST_SUMMARIZING, base.c_str()); ok = summarize(txtPath, mdPath, jobWav, err); } bool synced = false; if (ok && nextcloudEnabled()) { setState(ST_SYNCING, base.c_str()); if (strlen(NEXTCLOUD_PASSWORD) == 0) { err = "Notes are on the SD card. Add the Nextcloud app password to secrets.h"; ok = false; } else { ok = nextcloudUpload(mdPath, "text/markdown; charset=utf-8", err) && nextcloudUpload(txtPath, "text/plain; charset=utf-8", err); if (!ok) err = "Notes are on the SD card. " + err + ". Hold PLUS to retry"; synced = ok; } } if (ok) { NETLOG("SUCCESS: notes written to %s%s", mdPath.c_str(), synced ? " and copied to Nextcloud" : ""); setState(ST_DONE, synced ? "Also saved to Nextcloud /" NEXTCLOUD_FOLDER : (base + "_summary.md").c_str()); } else { NETLOG("FAILED: %s", err.c_str()); setState(ST_ERROR, err.c_str()); } busy = false; vTaskDelete(nullptr); } bool cloudStart(const char *wavPath) { if (busy) return false; busy = true; strlcpy(jobWav, wavPath, sizeof(jobWav)); // TLS handshakes need a deep stack. if (xTaskCreate(netTask, "net", 16384, nullptr, 2, nullptr) != pdPASS) { busy = false; return false; } return true; }