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 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -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 "."
|
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_board aes67_health aes67_net aes67_ota aes67_ptp aes67_sdp_sap
|
||||||
aes67_syslog aes67_tx aes67_web)
|
aes67_syslog aes67_tx aes67_web)
|
||||||
|
|||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
#include "hls.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
// HLS client: playlists, variant choice, live segment loop. Segment bytes go to a sink callback.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#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);
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "aes67_tx.h"
|
#include "aes67_tx.h"
|
||||||
#include "aes67_web.h"
|
#include "aes67_web.h"
|
||||||
#include "audio_ring.h"
|
#include "audio_ring.h"
|
||||||
|
#include "hls.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
@@ -112,5 +113,9 @@ esp_err_t player_init(void)
|
|||||||
player_apply(src);
|
player_apply(src);
|
||||||
cJSON_Delete(src);
|
cJSON_Delete(src);
|
||||||
xTaskCreate(test_producer, "test_prod", 3072, NULL, 5, NULL);
|
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);
|
return status_register(player_status);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user