Login
1 branch 0 tags
Ben (Desktop/Arch) Removed list border radius 1690c43 1 month ago 39 Commits
moon / firmware / esp32 / main / audio_player_esp32.c
#include "../../src/audio_player.h"
#include "../../src/audio_metadata.h"
#include "ogg_opus_decoder.h"
#include "esp_log.h"
#include "esp_audio_simple_player.h"
#include "esp_audio_simple_player_advance.h"
#include "esp_gmf_pipeline.h"
#include "esp_gmf_io.h"
#include "driver/i2s_std.h"
#include "freertos/FreeRTOS.h"
#include <string.h>

static const char *TAG = "audio_player";

// I2S pins
#define PIN_BCK   13
#define PIN_WS    14
#define PIN_DOUT  21

static esp_asp_handle_t player = NULL;
static i2s_chan_handle_t i2s_tx_chan = NULL;
static bool i2s_enabled = false;

static audio_state_t current_state = AUDIO_STATE_STOPPED;
static bool using_ogg_decoder = false;  // true when using custom OGG Opus path
static char current_filename[64] = {0};
static char current_display_title[128] = {0};  // "Artist - Title" or filename

// Position/duration tracking
static audio_metadata_t current_metadata = {0};  // Metadata from file parsing
static uint64_t pcm_bytes_written = 0;           // Accumulated PCM bytes written to I2S

// Seek state: stop/restart approach to avoid stale decoder buffers
static char current_path[160] = {0};       // Full filesystem path for restart
static uint64_t pending_seek_byte = 0;     // Absolute file offset for pending seek
static uint32_t pending_seek_ms = 0;       // Time position for PCM tracking
static bool seeking = false;               // Suppress event callback during seek restart

// Audio output callback - writes decoded PCM to I2S
static int audio_output_cb(uint8_t *data, int size, void *ctx) {
    if (i2s_tx_chan && data && size > 0) {
        // Enable I2S on first write to avoid playing stale DMA buffer data
        if (!i2s_enabled) {
            // Preload silence to overwrite stale DMA buffer contents
            uint8_t silence[256] = {0};
            size_t loaded;
            do {
                loaded = 0;
                i2s_channel_preload_data(i2s_tx_chan, silence, sizeof(silence), &loaded);
            } while (loaded > 0);
            i2s_channel_enable(i2s_tx_chan);
            i2s_enabled = true;
        }
        pcm_bytes_written += (uint64_t)size;
        size_t written = 0;
        i2s_channel_write(i2s_tx_chan, data, size, &written, portMAX_DELAY);
        return (int)written;
    }
    return 0;
}

// Event callback from simple player
static int player_event_cb(esp_asp_event_pkt_t *event, void *ctx) {
    if (event->type == ESP_ASP_EVENT_TYPE_MUSIC_INFO) {
        esp_asp_music_info_t info = {0};
        memcpy(&info, event->payload, event->payload_size);
        ESP_LOGI(TAG, "Music info: rate=%d, ch=%d, bits=%d, bitrate=%d",
                 info.sample_rate, info.channels, info.bits, (int)info.bitrate);

        // ESP-GMF resamples everything to 48000Hz (see ASP_POOL: Dest rate log)
        // Duration comes from metadata parsing, not from ESP-GMF
        ESP_LOGI(TAG, "Source format: %dHz, %dch, %dbit (output resampled to 48000Hz)",
                 info.sample_rate, info.channels, info.bits);
    } else if (event->type == ESP_ASP_EVENT_TYPE_STATE) {
        esp_asp_state_t state = 0;
        memcpy(&state, event->payload, event->payload_size);
        ESP_LOGI(TAG, "Player state: %s", esp_audio_simple_player_state_to_str(state));

        // Ignore state transitions during seek restart to prevent
        // the UI from misinterpreting the stop/restart as playback end
        if (seeking) {
            ESP_LOGI(TAG, "Ignoring state change during seek: %s",
                     esp_audio_simple_player_state_to_str(state));
        } else {
            switch (state) {
                case ESP_ASP_STATE_RUNNING:
                    current_state = AUDIO_STATE_PLAYING;
                    break;
                case ESP_ASP_STATE_PAUSED:
                    current_state = AUDIO_STATE_PAUSED;
                    break;
                case ESP_ASP_STATE_STOPPED:
                case ESP_ASP_STATE_FINISHED:
                    current_state = AUDIO_STATE_STOPPED;
                    break;
                case ESP_ASP_STATE_ERROR:
                    ESP_LOGE(TAG, "Playback error (file may be too large or unsupported)");
                    current_state = AUDIO_STATE_ERROR;
                    break;
                default:
                    break;
            }
        }
    }
    return 0;
}

static bool init_i2s(void) {
    if (i2s_tx_chan) {
        return true;
    }

    i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
    esp_err_t err = i2s_new_channel(&chan_cfg, &i2s_tx_chan, NULL);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to create I2S channel: %s", esp_err_to_name(err));
        return false;
    }

    // ESP-GMF resamples all audio to 48000Hz stereo 16-bit
    i2s_std_config_t std_cfg = {
        .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(48000),
        .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
        .gpio_cfg = {
            .mclk = I2S_GPIO_UNUSED,
            .bclk = PIN_BCK,
            .ws = PIN_WS,
            .dout = PIN_DOUT,
            .din = I2S_GPIO_UNUSED,
            .invert_flags = {
                .mclk_inv = false,
                .bclk_inv = false,
                .ws_inv = false,
            },
        },
    };

    err = i2s_channel_init_std_mode(i2s_tx_chan, &std_cfg);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to init I2S std mode: %s", esp_err_to_name(err));
        i2s_del_channel(i2s_tx_chan);
        i2s_tx_chan = NULL;
        return false;
    }

    ESP_LOGI(TAG, "I2S initialized (BCK=%d, WS=%d, DOUT=%d) at 48000Hz", PIN_BCK, PIN_WS, PIN_DOUT);
    i2s_enabled = false;  // Start disabled, enable on play
    return true;
}

// Called before pipeline starts running - used to seek file IO for resume.
// We set the IO position before the file opens; _file_open checks info.pos
// and fseeks to it after fopen, so the decoder starts at the right offset.
static int player_prev_cb(esp_asp_handle_t *handle, void *ctx) {
    (void)handle;
    (void)ctx;
    if (pending_seek_byte > 0 && player) {
        esp_gmf_pipeline_handle_t pipe = NULL;
        esp_gmf_err_t err = esp_audio_simple_player_get_pipeline(player, &pipe);
        if (err == ESP_GMF_ERR_OK && pipe) {
            esp_gmf_io_handle_t io = NULL;
            err = esp_gmf_pipeline_get_in(pipe, &io);
            if (err == ESP_GMF_ERR_OK && io) {
                err = esp_gmf_io_set_pos(io, pending_seek_byte);
                if (err != ESP_GMF_ERR_OK) {
                    ESP_LOGE(TAG, "Pre-run set_pos failed: %d", err);
                } else {
                    ESP_LOGI(TAG, "Pre-run set_pos to byte %llu", (unsigned long long)pending_seek_byte);
                }
            } else {
                ESP_LOGE(TAG, "Failed to get pipeline input IO: %d", err);
            }
        }
        pcm_bytes_written = (uint64_t)pending_seek_ms * 192;
        pending_seek_byte = 0;
        pending_seek_ms = 0;
    }
    return 0;
}

static bool init_player(void) {
    if (player) {
        return true;
    }

    esp_asp_cfg_t cfg = {
        .in.cb = NULL,           // Use built-in file IO
        .in.user_ctx = NULL,
        .out.cb = audio_output_cb,
        .out.user_ctx = NULL,
        .task_prio = 5,
        .task_stack = 8192,
        .prev = player_prev_cb,
    };

    esp_gmf_err_t err = esp_audio_simple_player_new(&cfg, &player);
    if (err != ESP_GMF_ERR_OK || !player) {
        ESP_LOGE(TAG, "Failed to create simple player: %d", err);
        return false;
    }

    err = esp_audio_simple_player_set_event(player, player_event_cb, NULL);
    if (err != ESP_GMF_ERR_OK) {
        ESP_LOGE(TAG, "Failed to set event callback: %d", err);
        esp_audio_simple_player_destroy(player);
        player = NULL;
        return false;
    }

    ESP_LOGI(TAG, "ESP-GMF audio simple player initialized");
    return true;
}

static bool is_ogg_file(const char *path) {
    const char *ext = strrchr(path, '.');
    if (!ext) return false;
    return (strcasecmp(ext, ".opus") == 0 || strcasecmp(ext, ".ogg") == 0);
}

bool audio_player_init(void) {
    ESP_LOGI(TAG, "Audio player init");
    return true;  // Lazy initialization
}

bool audio_player_play(const char *path) {
    audio_player_stop();

    // Reset position tracking
    pcm_bytes_written = 0;
    memset(&current_metadata, 0, sizeof(current_metadata));

    // Get audio metadata (duration, tags, etc.)
    char full_path[160];
    snprintf(full_path, sizeof(full_path), "/sdcard%s", path);
    if (audio_metadata_get(full_path, &current_metadata)) {
        ESP_LOGI(TAG, "Metadata: duration=%lums, bitrate=%lu, title=%s, artist=%s",
                 (unsigned long)current_metadata.duration_ms,
                 (unsigned long)current_metadata.bitrate,
                 current_metadata.title,
                 current_metadata.artist);

        // Build display title: "Artist - Title" or just "Title" or filename
        if (current_metadata.artist[0] && current_metadata.title[0]) {
            snprintf(current_display_title, sizeof(current_display_title), "%.60s - %.60s",
                     current_metadata.artist, current_metadata.title);
        } else if (current_metadata.title[0]) {
            snprintf(current_display_title, sizeof(current_display_title), "%.127s",
                     current_metadata.title);
        } else {
            current_display_title[0] = '\0';  // Will fall back to filename
        }
    } else {
        ESP_LOGW(TAG, "Failed to parse metadata for: %s", full_path);
        current_display_title[0] = '\0';
    }

    if (!init_i2s()) {
        return false;
    }

    // Extract filename for display
    const char *name = strrchr(path, '/');
    name = name ? name + 1 : path;
    strncpy(current_filename, name, sizeof(current_filename) - 1);
    current_filename[sizeof(current_filename) - 1] = '\0';

    // OGG/Opus files need custom decoder (ESP-GMF has no OGG demuxer)
    if (is_ogg_file(path)) {
        ESP_LOGI(TAG, "Playing (OGG Opus): %s", full_path);
        using_ogg_decoder = true;
        if (!ogg_opus_start(full_path, audio_output_cb, NULL)) {
            ESP_LOGE(TAG, "Failed to start OGG Opus decoder");
            using_ogg_decoder = false;
            return false;
        }
        current_state = AUDIO_STATE_PLAYING;
        return true;
    }

    // I2S is enabled lazily in audio_output_cb to avoid playing stale DMA data

    if (!init_player()) {
        return false;
    }

    // Save path for seek restart
    snprintf(current_path, sizeof(current_path), "%s", path);

    // Build URI: file://sdcard/path
    char uri[160];
    snprintf(uri, sizeof(uri), "file://sdcard%s", path);

    ESP_LOGI(TAG, "Playing: %s", uri);
    esp_gmf_err_t err = esp_audio_simple_player_run(player, uri, NULL);
    if (err != ESP_GMF_ERR_OK) {
        ESP_LOGE(TAG, "Failed to start playback: %d", err);
        return false;
    }

    current_state = AUDIO_STATE_PLAYING;
    return true;
}

void audio_player_stop(void) {
    if (using_ogg_decoder) {
        ogg_opus_stop();
        using_ogg_decoder = false;
    } else if (player && current_state != AUDIO_STATE_STOPPED) {
        esp_audio_simple_player_stop(player);
    }
    // Disable I2S to stop any residual noise
    if (i2s_tx_chan && i2s_enabled) {
        i2s_channel_disable(i2s_tx_chan);
        i2s_enabled = false;
    }
    current_state = AUDIO_STATE_STOPPED;
    current_filename[0] = '\0';
    current_path[0] = '\0';
}

void audio_player_pause(void) {
    if (current_state == AUDIO_STATE_PLAYING) {
        if (using_ogg_decoder) {
            ogg_opus_pause();
        } else if (player) {
            esp_audio_simple_player_pause(player);
        }
        // Disable I2S to stop residual noise
        if (i2s_tx_chan && i2s_enabled) {
            i2s_channel_disable(i2s_tx_chan);
            i2s_enabled = false;
        }
        current_state = AUDIO_STATE_PAUSED;
    }
}

void audio_player_resume(void) {
    if (current_state == AUDIO_STATE_PAUSED) {
        // Re-enable I2S before resuming
        if (i2s_tx_chan && !i2s_enabled) {
            i2s_channel_enable(i2s_tx_chan);
            i2s_enabled = true;
        }
        if (using_ogg_decoder) {
            ogg_opus_resume();
        } else if (player) {
            esp_audio_simple_player_resume(player);
        }
        current_state = AUDIO_STATE_PLAYING;
    }
}

audio_state_t audio_player_get_state(void) {
    return current_state;
}

void audio_player_set_volume(uint8_t volume) {
    // Volume control would require ALC element in the pipeline
    // For now, this is a placeholder
    (void)volume;
}

const char *audio_player_get_filename(void) {
    return current_filename[0] ? current_filename : NULL;
}

const char *audio_player_get_title(void) {
    if (current_display_title[0]) {
        return current_display_title;
    }
    return current_filename[0] ? current_filename : NULL;
}

void audio_player_update(void) {
    // ESP-GMF handles everything in its own task
    // Nothing needed here
}

uint32_t audio_player_get_position_ms(void) {
    // ESP-GMF resamples to 48kHz stereo 16-bit
    // 48000 Hz * 2 channels * 2 bytes = 192000 bytes/sec = 192 bytes/ms
    return (uint32_t)(pcm_bytes_written / 192);
}

uint32_t audio_player_get_duration_ms(void) {
    return current_metadata.duration_ms;
}

uint8_t audio_player_get_progress(void) {
    if (current_metadata.duration_ms == 0) {
        return 0;
    }
    uint32_t position = audio_player_get_position_ms();
    uint32_t progress = (position * 100) / current_metadata.duration_ms;
    return progress > 100 ? 100 : (uint8_t)progress;
}

bool audio_player_seek_ms(uint32_t position_ms) {
    if (using_ogg_decoder || current_metadata.bitrate == 0 || current_path[0] == '\0') {
        ESP_LOGW(TAG, "Cannot seek: ogg=%d, bitrate=%lu, path=%s",
                 using_ogg_decoder, (unsigned long)current_metadata.bitrate,
                 current_path[0] ? current_path : "(none)");
        return false;
    }
    if (!player) {
        return false;
    }

    // Calculate absolute byte offset: audio_data_offset + compressed audio offset
    uint64_t audio_offset = ((uint64_t)position_ms * current_metadata.bitrate) / 8000;
    uint64_t absolute_offset = current_metadata.audio_data_offset + audio_offset;
    ESP_LOGI(TAG, "Seeking to %lums (data_offset=%lu + audio=%llu = abs=%llu, bitrate=%lu)",
             (unsigned long)position_ms,
             (unsigned long)current_metadata.audio_data_offset,
             (unsigned long long)audio_offset,
             (unsigned long long)absolute_offset,
             (unsigned long)current_metadata.bitrate);

    // Store pending seek for the prev callback
    pending_seek_byte = absolute_offset;
    pending_seek_ms = position_ms;

    // Set seeking flag to suppress event callback state changes during restart
    seeking = true;

    // Disable I2S to avoid noise during restart
    if (i2s_tx_chan && i2s_enabled) {
        i2s_channel_disable(i2s_tx_chan);
        i2s_enabled = false;
    }

    // Stop the pipeline (but don't clear metadata/filename/path)
    esp_audio_simple_player_stop(player);

    // Rebuild URI and restart - the prev callback will seek before decode starts
    char uri[180];
    snprintf(uri, sizeof(uri), "file://sdcard%s", current_path);

    ESP_LOGI(TAG, "Restarting pipeline for seek: %s", uri);
    esp_gmf_err_t err = esp_audio_simple_player_run(player, uri, NULL);
    if (err != ESP_GMF_ERR_OK) {
        ESP_LOGE(TAG, "Failed to restart for seek: %d", err);
        seeking = false;
        pending_seek_byte = 0;
        pending_seek_ms = 0;
        current_state = AUDIO_STATE_ERROR;
        return false;
    }

    seeking = false;
    current_state = AUDIO_STATE_PLAYING;
    ESP_LOGI(TAG, "Seek complete (stop/restart)");
    return true;
}