Skip to content

mp3 拆分 ​

1. 项目背景与需求分析 ​

目标:
将一个大的 MP3 文件按用户指定的大小拆分为多个小文件。拆分后,每个文件必须满足以下条件:

  • 拆分点处不能破坏 MP3 帧结构;
  • 每个拆分文件均为有效的 MP3 文件,可供语音识别模型处理;
  • 返回拆分后文件的路径(以字符串数组形式返回给 Java 层)。

关键问题:

  • MP3 帧结构:
    MP3 文件由一系列帧组成,每个帧包含帧头和音频数据。帧头中包含同步字、比特率、采样率、填充位等信息。直接按字节拆分可能会截断帧,从而导致文件无法播放或被识别模型错误处理。

  • ID3v2 标签处理:
    MP3 文件开头可能存在 ID3v2 标签,用于存储元数据。拆分时需要判断并跳过这些数据,直接处理后续的音频帧。

  • 拆分策略:
    以完整帧为最小单位进行拆分,累计帧数据的大小。当累计大小即将超过用户指定的 size 时,结束当前文件并新建一个拆分文件继续写入。由于必须以完整帧为单位,拆分文件的大小可能略大于用户给定的 size,但能保证文件的完整性。

  • 依赖要求:
    尽可能减少外部依赖(如不使用 FFmpeg 等库),因此采用自定义实现方式解析 MP3 帧。


2. 方案设计与实现步骤 ​

2.1 MP3 帧解析 ​

  • 帧头同步字:
    每个有效 MP3 帧的头信息以 0xFF 开头,接着至少 3 个比特为 1(0xE0)。通过检测同步字,可以判断是否处于帧头位置。

  • 解析版本、层、比特率和采样率:
    根据 MP3 帧头格式,解析出 MPEG 版本、Layer(层)、比特率索引和采样率索引。本文档中示例代码只支持 MPEG-1 Layer III 格式。

  • 计算帧长度:
    帧长度计算公式如下(对于 MPEG-1 Layer III):

    frame_length = (144 * bitrate) / samplerate + padding

    其中 padding 为 0 或 1,由填充位决定。

2.2 ID3v2 标签处理 ​

  • 检测与跳过:
    读取文件开头 10 个字节,如果前三个字节为 "ID3",则说明存在 ID3v2 标签。接着根据标签头中存储的大小信息(以同步安全整数存储)计算标签大小,并跳过整个标签区域,确保后续处理的是音频帧数据。

2.3 按帧拆分逻辑 ​

  • 逐帧读取:
    从文件中依次读取每个 MP3 帧(先读 4 字节帧头,再计算整帧长度,回退到帧头起始处,读取完整帧数据)。

  • 拆分判断:
    记录当前拆分文件已写入的字节数 current_size。如果再写入当前帧将导致文件大小超过用户指定的 size,则关闭当前文件,创建新文件,然后再写入该帧数据。

  • 文件名处理:
    根据原始文件路径,去掉扩展名后生成输出文件名。例如:
    /path/to/file.mp3 拆分后生成 /path/to/file_part1.mp3, /path/to/file_part2.mp3 等。

  • 返回值:
    在 JNI 函数结束时,将所有生成的拆分文件路径以 Java 字符串数组的形式返回给上层调用。

2.4 测试方案 ​

  • C 语言测试:
    可在 C 的 main 函数中调用拆分函数(或模拟 JNI 调用),传入测试文件路径与拆分大小,验证拆分后的文件是否生成且数据正确。

  • Java 单元测试:
    编写 JUnit 单元测试,通过调用 NativeMedia.splitMp3 方法,检查返回的文件路径数组,验证拆分后的文件是否存在且能被播放器或语音识别模型正确处理。


3. 完整代码示例 ​

下面是基于上述设计的完整 C 代码示例,代码经过测试后可正确拆分 MP3 文件(假设输入为 MPEG-1 Layer III 格式):

native_mp3_split.c

cpp
#include "com_litongjava_media_NativeMedia.h"
#include <jni.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#ifdef _WIN32
#include <stringapiset.h>
#endif

typedef struct {
  int version;
  int layer;
  int bitrate;    // bps
  int samplerate;  // Hz
  int frame_length;
} MP3FrameInfo;

int parse_mp3_frame(const unsigned char *header, MP3FrameInfo *info) {
  // Verify sync word
  if (header[0] != 0xFF || (header[1] & 0xE0) != 0xE0) {
    return 0;
  }

  // MPEG version
  int version_bits = (header[1] >> 3) & 0x03;
  if (version_bits == 0x03) info->version = 3;  // MPEG-1
  else if (version_bits == 0x02) info->version = 2;  // MPEG-2
  else return 0;

  // Layer
  int layer_bits = (header[1] >> 1) & 0x03;
  if (layer_bits == 0x01) info->layer = 3;
  else return 0;

  // Bitrate index
  int bitrate_index = (header[2] >> 4) & 0x0F;
  const int bitrate_table[16] = {
    0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0
  };
  info->bitrate = bitrate_table[bitrate_index] * 1000;

  // Sample rate
  int samplerate_index = (header[2] >> 2) & 0x03;
  const int samplerate_table[4] = {44100, 48000, 32000, 0};
  info->samplerate = samplerate_table[samplerate_index];

  if (info->bitrate == 0 || info->samplerate == 0) return 0;

  // Calculate frame length
  info->frame_length = (144 * info->bitrate) / info->samplerate;
  if ((header[2] >> 1) & 0x01) info->frame_length++;  // Padding

  return 1;
}

JNIEXPORT jobjectArray JNICALL
Java_com_litongjava_media_NativeMedia_splitMp3(JNIEnv *env, jclass clazz, jstring srcPath, jlong size) {
  const char *src_path = (*env)->GetStringUTFChars(env, srcPath, NULL);
  if (!src_path) return NULL;

  // Prepare output filenames
  char base_path[1024];
  strncpy(base_path, src_path, sizeof(base_path) - 1);
  base_path[sizeof(base_path) - 1] = '\0';

  // Remove extension
  char *dot = strrchr(base_path, '.');
  if (dot && strcasecmp(dot, ".mp3") == 0) *dot = '\0';

#ifdef _WIN32
  int wlen = MultiByteToWideChar(CP_UTF8, 0, src_path, -1, NULL, 0);
  wchar_t *wsrc_path = malloc(wlen * sizeof(wchar_t));
  MultiByteToWideChar(CP_UTF8, 0, src_path, -1, wsrc_path, wlen);
  FILE *input = _wfopen(wsrc_path, L"rb");
  free(wsrc_path);
#else
  FILE *input = fopen(src_path, "rb");
#endif

  if (!input) {
    (*env)->ReleaseStringUTFChars(env, srcPath, src_path);
    return NULL;
  }

  // Skip ID3v2 tag
  unsigned char id3_header[10];
  if (fread(id3_header, 1, 10, input) == 10 &&
      memcmp(id3_header, "ID3", 3) == 0) {
    long tag_size = (id3_header[6] << 21) | (id3_header[7] << 14) |
                    (id3_header[8] << 7) | id3_header[9];
    fseek(input, tag_size + 10, SEEK_SET);
  } else {
    fseek(input, 0, SEEK_SET);
  }

  int split_count = 0;
  size_t current_size = 0;
  FILE *output = NULL;
  char output_path[1024];
  const size_t max_size = (size_t) size;

  // Frame reading buffer
  unsigned char header[4];
  MP3FrameInfo frame_info;

  while (fread(header, 1, 4, input) == 4) {
    if (!parse_mp3_frame(header, &frame_info)) {
      fseek(input, -3, SEEK_CUR);
      continue;
    }

    if (frame_info.frame_length <= 4) {
      fseek(input, -3, SEEK_CUR);
      continue;
    }

    if (!output || current_size + frame_info.frame_length > max_size) {
      if (output) fclose(output);
      split_count++;
      snprintf(output_path, sizeof(output_path), "%s_part%d.mp3", base_path, split_count);
#ifdef _WIN32
      int wlen = MultiByteToWideChar(CP_UTF8, 0, output_path, -1, NULL, 0);
      wchar_t *woutput_path = malloc(wlen * sizeof(wchar_t));
      MultiByteToWideChar(CP_UTF8, 0, output_path, -1, woutput_path, wlen);
      output = _wfopen(woutput_path, L"wb");
      free(woutput_path);
#else
      output = fopen(output_path, "wb");
#endif
      if (!output) break;
      current_size = 0;
    }

    fseek(input, -4, SEEK_CUR);
    unsigned char *frame = malloc(frame_info.frame_length);
    if (!frame || fread(frame, 1, frame_info.frame_length, input) != frame_info.frame_length) {
      free(frame);
      break;
    }

    fwrite(frame, 1, frame_info.frame_length, output);
    free(frame);
    current_size += frame_info.frame_length;
  }

  if (output) fclose(output);
  fclose(input);
  (*env)->ReleaseStringUTFChars(env, srcPath, src_path);

  // Build result array
  jclass stringClass = (*env)->FindClass(env, "java/lang/String");
  jobjectArray result = (*env)->NewObjectArray(env, split_count, stringClass, NULL);

  for (int i = 0; i < split_count; i++) {
    char path[1024];
    snprintf(path, sizeof(path), "%s_part%d.mp3", base_path, i + 1);
    jstring str = (*env)->NewStringUTF(env, path);
    (*env)->SetObjectArrayElement(env, result, i, str);
    (*env)->DeleteLocalRef(env, str);
  }

  return result;
}

4. 测试方法说明 ​

4.1 在 C 的 main 函数中测试 ​

可以编写一个简单的 main 函数,通过传入测试用的 MP3 文件路径和指定拆分大小,调用上述拆分逻辑。注意:

  • 需要预先准备一个 MPEG-1 Layer III 格式的 MP3 文件。
  • 通过检查生成的 _partX.mp3 文件是否存在且能正常播放,验证拆分逻辑正确性。

4.2 Java 单元测试 ​

在 Java 层,可编写 JUnit 测试用例调用 NativeMedia.splitMp3 方法,验证返回的路径数组。测试步骤如下:

  1. 准备一个测试 MP3 文件,并确保其格式正确;
  2. 调用 NativeMedia.splitMp3(测试文件路径, 指定大小);
  3. 遍历返回的文件路径数组,检查每个拆分文件是否存在;
  4. 根据需要可使用第三方工具或播放器对生成文件进行进一步验证。
java
package nexus.io.media;

import org.junit.Test;

public class NativeMediaTest {

  public static void main(String[] args) {
    NativeMedia.splitMp3("1234", 0);
  }

  @Test
  public void testSplitMp3() {
    String testFile = "E:\\code\\java\\project-litongjava\\yt-dlp-java\\downloads\\490920099690696704\\01.mp3";
    //long splitSize = 25 * 1024 * 1024; // 25MB
    long splitSize = 10 * 1024 * 1024; // 10MB

    String[] result = NativeMedia.splitMp3(testFile, splitSize);
    for (String string : result) {
      System.out.println(string);
    }

  }
}

5. 总结 ​

本文档详细介绍了在 Java JNI 环境下实现 MP3 拆分的方案,关键在于正确解析 MP3 帧和处理 ID3v2 标签,从而保证拆分后文件的完整性和有效性。示例代码展示了如何:

  • 跳过 ID3v2 标签;
  • 解析 MP3 帧头并计算帧长度;
  • 按帧拆分 MP3 文件,并生成拆分后的文件路径数组返回给 Java 层;
  • 结合 C 端与 Java 端测试方法,确保功能正确。

通过以上方案和代码,开发者可以根据具体需求做进一步扩展和优化(例如支持更多 MP3 格式、处理异常情况等),最终实现一个高效且体积较小的 MP3 拆分模块。

MIT Licensed