From 852c0ffa0f1702bcae66e4f02d16f46e5f4ca509 Mon Sep 17 00:00:00 2001 From: Ben Nicholson Date: Fri, 25 Sep 2026 14:40:18 +1000 Subject: [PATCH] Step 7.2: HLS client fetches live segments (log only) - main/hls: task active while source.mode is hls/auto and hls_url is set. esp_http_client over HTTPS (IDF certificate bundle), redirects followed (max 5), streamed reads in 4 KB chunks to a sink callback. - Master playlist: highest BANDWIDTH variant; URL resolution for absolute, host-relative and path-relative references; CRLF tolerant. - Media playlist: TARGETDURATION, MEDIA-SEQUENCE, segments; start 3 segments behind the live edge, fetch each new one in order, skip ahead if the window moved past us, reload after target/2 when nothing is new. - Verified with Triple J Hottest (ABC, Akamai): 252 kbit/s AAC-LC variant chosen, 3 back-fill segments then one new ~10 s segment at a time, ~303 KB in ~1.25 s each; heap 439 KB, PSRAM 31.9 MB free. Co-Authored-By: Claude Opus 5.5 --- main/CMakeLists.txt | 4 +- main/hls.c | 307 ++++++++++++++++++++++++++++++++++++++++++++ main/hls.h | 13 ++ main/player.c | 5 + 4 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 main/hls.c create mode 100644 main/hls.h diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f62988f..535cf9a 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,5 +1,5 @@ -idf_component_register(SRCS "main.c" "project_cfg.c" "player.c" "audio_ring.c" +idf_component_register(SRCS "main.c" "project_cfg.c" "player.c" "audio_ring.c" "hls.c" INCLUDE_DIRS "." - REQUIRES esp_app_format esp_hw_support heap + REQUIRES esp_app_format esp_hw_support heap esp_http_client mbedtls esp_timer aes67_board aes67_health aes67_net aes67_ota aes67_ptp aes67_sdp_sap aes67_syslog aes67_tx aes67_web) diff --git a/main/hls.c b/main/hls.c new file mode 100644 index 0000000..c19c195 --- /dev/null +++ b/main/hls.c @@ -0,0 +1,307 @@ +#include "hls.h" + +#include +#include +#include + +#include "aes67_cfg.h" +#include "esp_crt_bundle.h" +#include "esp_heap_caps.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define URL_MAX 384 +#define PLAYLIST_MAX (64 * 1024) +#define MAX_SEGMENTS 64 +#define LIVE_BACK 3 // start this many segments behind the live edge +#define CHUNK 4096 +#define MAX_REDIRECTS 5 + +static const char *TAG = "hls"; + +typedef struct { + int target_s; + long long media_seq; + int count; + char uri[MAX_SEGMENTS][URL_MAX]; +} media_pl_t; + +static hls_sink_t s_sink; +static char *s_text; // playlist buffer (PSRAM) +static media_pl_t *s_pl; // parsed media playlist (PSRAM) +static char s_url[URL_MAX]; // configured URL +static char s_media_url[URL_MAX]; + +// hls_url when source.mode needs HLS, else "". +static void wanted_url(char *out, size_t n) +{ + cJSON *src = cfg_get("source"); + const char *mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring; + const char *url = cJSON_GetObjectItemCaseSensitive(src, "hls_url")->valuestring; + bool on = !strcmp(mode, "hls") || !strcmp(mode, "auto"); + strlcpy(out, on ? url : "", n); + cJSON_Delete(src); +} + +static bool still_wanted(void) +{ + char u[URL_MAX]; + wanted_url(u, sizeof(u)); + return strcmp(u, s_url) == 0; +} + +/* ----- HTTP ----- */ + +// Open a GET and follow redirects. Returns the client with headers read, or NULL. +static esp_http_client_handle_t http_open(const char *url) +{ + esp_http_client_config_t cfg = { + .url = url, .crt_bundle_attach = esp_crt_bundle_attach, .timeout_ms = 10000, + .buffer_size = CHUNK, .buffer_size_tx = 1024, .user_agent = "p4-aes67", + .disable_auto_redirect = true, .keep_alive_enable = true, + }; + esp_http_client_handle_t c = esp_http_client_init(&cfg); + if (!c) { + return NULL; + } + for (int i = 0; i <= MAX_REDIRECTS; i++) { + esp_err_t err = esp_http_client_open(c, 0); + if (err != ESP_OK) { + ESP_LOGW(TAG, "open %s: %s", url, esp_err_to_name(err)); + break; + } + esp_http_client_fetch_headers(c); + int st = esp_http_client_get_status_code(c); + if (st == 301 || st == 302 || st == 303 || st == 307 || st == 308) { + esp_http_client_flush_response(c, NULL); + esp_http_client_set_redirection(c); + esp_http_client_close(c); + continue; + } + if (st == 200) { + return c; + } + ESP_LOGW(TAG, "GET %s: HTTP %d", url, st); + break; + } + esp_http_client_cleanup(c); + return NULL; +} + +// GET into s_text (NUL-terminated). Returns length or -1. +static int http_get_text(const char *url) +{ + esp_http_client_handle_t c = http_open(url); + if (!c) { + return -1; + } + int n = 0, r; + while (n < PLAYLIST_MAX - 1 && (r = esp_http_client_read(c, s_text + n, PLAYLIST_MAX - 1 - n)) > 0) { + n += r; + } + s_text[n] = 0; + esp_http_client_close(c); + esp_http_client_cleanup(c); + return n; +} + +/* ----- playlists ----- */ + +// Absolute, host-relative ("/x") or path-relative ("x") reference against base. +static void resolve(char *out, size_t n, const char *base, const char *ref) +{ + if (strstr(ref, "://")) { + strlcpy(out, ref, n); + return; + } + char b[URL_MAX]; + strlcpy(b, base, sizeof(b)); + char *q = strchr(b, '?'); + if (q) { + *q = 0; + } + if (ref[0] == '/') { + char *host_end = strchr(strstr(b, "://") + 3, '/'); + if (host_end) { + *host_end = 0; + } + } else { + char *slash = strrchr(b, '/'); + if (slash) { + slash[1] = 0; + } + } + snprintf(out, n, "%s%s", b, ref); +} + +// Next line of s_text (CRLF tolerant); NULL at the end. +static char *next_line(char **p) +{ + if (!*p || !**p) { + return NULL; + } + char *line = *p, *e = strchr(line, '\n'); + *p = e ? e + 1 : NULL; + if (e) { + *e = 0; + } + size_t l = strlen(line); + while (l && (line[l - 1] == '\r' || line[l - 1] == ' ')) { + line[--l] = 0; + } + return line; +} + +// Master playlist: pick the variant with the highest BANDWIDTH. false if s_text is a media playlist. +static bool pick_variant(const char *base, char *out, size_t n) +{ + if (!strstr(s_text, "#EXT-X-STREAM-INF")) { + return false; + } + long best = -1, bw = -1; + char *p = s_text, *line; + while ((line = next_line(&p))) { + if (!strncmp(line, "#EXT-X-STREAM-INF:", 18)) { + const char *b = strstr(line, "BANDWIDTH="); + bw = b ? atol(b + 10) : 0; + } else if (line[0] && line[0] != '#' && bw >= 0) { + if (bw > best) { + best = bw; + resolve(out, n, base, line); + } + bw = -1; + } + } + ESP_LOGI(TAG, "variant %ld bit/s: %s", best, out); + return best >= 0; +} + +static bool parse_media(const char *base) +{ + s_pl->target_s = 10; + s_pl->media_seq = 0; + s_pl->count = 0; + char *p = s_text, *line; + bool is_m3u = false; + while ((line = next_line(&p))) { + if (!strcmp(line, "#EXTM3U")) { + is_m3u = true; + } else if (!strncmp(line, "#EXT-X-TARGETDURATION:", 22)) { + s_pl->target_s = atoi(line + 22); + } else if (!strncmp(line, "#EXT-X-MEDIA-SEQUENCE:", 22)) { + s_pl->media_seq = atoll(line + 22); + } else if (line[0] && line[0] != '#' && s_pl->count < MAX_SEGMENTS) { + resolve(s_pl->uri[s_pl->count++], URL_MAX, base, line); + } + } + return is_m3u && s_pl->count > 0; +} + +/* ----- segments ----- */ + +static bool fetch_segment(long long seq, const char *url) +{ + int64_t t0 = esp_timer_get_time(); + esp_http_client_handle_t c = http_open(url); + if (!c) { + return false; + } + static uint8_t buf[CHUNK]; + size_t total = 0; + int r; + bool ok = true, first = true; + while ((r = esp_http_client_read(c, (char *)buf, sizeof(buf))) > 0) { + if (s_sink && !s_sink(buf, r, first)) { + ok = false; + break; + } + first = false; + total += r; + if (!still_wanted()) { + ok = false; + break; + } + } + if (r < 0) { + ok = false; + } + esp_http_client_close(c); + esp_http_client_cleanup(c); + int64_t us = esp_timer_get_time() - t0; + ESP_LOGI(TAG, "segment %lld: %u bytes in %.2f s (%.1f Mbit/s)%s", seq, (unsigned)total, us / 1e6, + us ? total * 8.0 / us : 0.0, ok ? "" : ", aborted"); + return ok; +} + +static void hls_task(void *arg) +{ + while (1) { + wanted_url(s_url, sizeof(s_url)); + if (!s_url[0]) { + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + ESP_LOGI(TAG, "start: %s", s_url); + // Master -> media playlist + if (http_get_text(s_url) < 0) { + vTaskDelay(pdMS_TO_TICKS(5000)); + continue; + } + if (!pick_variant(s_url, s_media_url, sizeof(s_media_url))) { + strlcpy(s_media_url, s_url, sizeof(s_media_url)); + } + long long next = -1; + int errors = 0; + while (still_wanted() && errors < 5) { + int64_t t0 = esp_timer_get_time(); + if (http_get_text(s_media_url) < 0 || !parse_media(s_media_url)) { + errors++; + vTaskDelay(pdMS_TO_TICKS(2000)); + continue; + } + long long first = s_pl->media_seq, last = first + s_pl->count - 1; + if (next < 0) { + next = last - LIVE_BACK + 1 > first ? last - LIVE_BACK + 1 : first; + ESP_LOGI(TAG, "live playlist: seq %lld..%lld, target %d s, starting at %lld", + first, last, s_pl->target_s, next); + } else if (next < first) { + ESP_LOGW(TAG, "fell behind the live window (%lld < %lld), skipping ahead", next, first); + next = first; + } + bool got_new = false; + for (; next <= last && still_wanted(); next++) { + if (!fetch_segment(next, s_pl->uri[next - first])) { + errors++; + break; + } + errors = 0; + got_new = true; + } + // No new segment: wait half the target duration before reloading (RFC 8216 6.3.4). + if (!got_new) { + int64_t wait = s_pl->target_s * 500000LL - (esp_timer_get_time() - t0); + if (wait > 0) { + vTaskDelay(pdMS_TO_TICKS(wait / 1000)); + } + } + } + if (errors >= 5) { + ESP_LOGW(TAG, "too many errors, restarting from the playlist in 5 s"); + vTaskDelay(pdMS_TO_TICKS(5000)); + } + } +} + +esp_err_t hls_start(hls_sink_t sink) +{ + s_sink = sink; + s_text = heap_caps_malloc(PLAYLIST_MAX, MALLOC_CAP_SPIRAM); + s_pl = heap_caps_malloc(sizeof(media_pl_t), MALLOC_CAP_SPIRAM); + if (!s_text || !s_pl) { + return ESP_ERR_NO_MEM; + } + return xTaskCreate(hls_task, "hls", 10240, NULL, 4, NULL) == pdPASS ? ESP_OK : ESP_ERR_NO_MEM; +} diff --git a/main/hls.h b/main/hls.h new file mode 100644 index 0000000..0c791c4 --- /dev/null +++ b/main/hls.h @@ -0,0 +1,13 @@ +// HLS client: playlists, variant choice, live segment loop. Segment bytes go to a sink callback. +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +// Receives segment data in chunks (MPEG-TS etc.). Return false to abort the segment. +typedef bool (*hls_sink_t)(const uint8_t *data, size_t len, bool segment_start); + +esp_err_t hls_start(hls_sink_t sink); diff --git a/main/player.c b/main/player.c index 4c4fc8d..84ea666 100644 --- a/main/player.c +++ b/main/player.c @@ -7,6 +7,7 @@ #include "aes67_tx.h" #include "aes67_web.h" #include "audio_ring.h" +#include "hls.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -112,5 +113,9 @@ esp_err_t player_init(void) player_apply(src); cJSON_Delete(src); xTaskCreate(test_producer, "test_prod", 3072, NULL, 5, NULL); + err = hls_start(NULL); // 7.2: fetch and log only; decoding in 7.3 + if (err != ESP_OK) { + return err; + } return status_register(player_status); }