Skill: luis1213899/xia-zhuan-audio (version 1.3.1)

--- name: xia-zhuan-audio description: 🎵 音视频格式转换与处理工具箱。基于 FFmpeg + Whisper AI，支持：格式转换、视频提取音频、合并、分割、压缩、查看信息、音频转文字。 license: MIT metadata: homepage: "https://github.com/luis12123899/xia-zhuan-audio" emoji: "🎵" requires: bins: - ffmpeg env: - XZA_FFMPEG - XZA_FFPROBE - XZA_SCRIPTDIR - XZA_MODELDIR - HF_ENDPOINT --- # 虾转音频 (xia-zhuan-audio) ## 功能列表 | # | 功能 | 说明 | |---|------|------| | 1 | 格式转换 | m4a→mp3, wav→flac, ape→flac, aac→mp3 等，支持 20+ 格式 | | 2 | 视频提取音频 | mp4/mkv/avi/flv → mp3/aac/wav 等 | | 3 | 合并音频 | 将多个音频拼接成一个文件 | | 4 | 分割音频 | 按时间范围截取片段 | | 5 | 压缩音频 | 减小文件体积（64k/128k/192k） | | 6 | 查看音频信息 | 时长 / 码率 / 采样率 / 声道 / 元数据 | | 7 | 音频转文字 | Whisper AI 自动转录，支持 txt / srt / vtt / json | ## 支持的格式 **音频格式：** mp3, wav, flac, aac, m4a, ogg, wma, aiff, opus, ape, alac **视频格式：** mp4, mkv, avi, mov, flv, wmv, webm ## 环境变量配置 | 变量 | 说明 | 默认值 | |------|------|--------| | `XZA_FFMPEG` | FFmpeg/FFprobe 路径 | 系统 PATH 中查找 | | `XZA_FFPROBE` | FFprobe 路径 | 从 XZA_FFMPEG 推断 | | `XZA_SCRIPTDIR` | 脚本目录 | 自动检测 | | `XZA_MODELDIR` | Whisper 模型保存目录 | 技能目录下的 whisper_models | | `HF_ENDPOINT` | HuggingFace 模型下载源 | https://huggingface.co（官方）| ## 依赖 - **FFmpeg** — 系统已有或从 https://ffmpeg.org 下载，并在 PATH 中可用 - **Python** — 系统已有（用于音频转文字） - **faster-whisper** — 运行 `pip install faster-whisper` 安装 ### Whisper 模型安全说明 模型通过 HuggingFace 官方源下载（默认），如需使用国内镜像： ```bash set HF_ENDPOINT=https://hf-mirror.com ``` ## 使用方式 ### 通过 OpenClaw 对话触发（推荐） 直接用自然语言描述需求，例如： - "把这段 m4a 转成 mp3" - "把这个视频的音频提取出来" - "把这几个音频合并成一个" - "把这段音频 1:30 到 2:45 的部分截出来" - "压缩这个音频，128kbps" - "查看这个音频的信息" - "把这段录音转成文字" ### 通过命令行直接调用 ```bash # 格式转换 node audio-forge.js convert <输入> <输出格式> [--bitrate 192k] # 视频提取音频 node audio-forge.js extract <视频> [输出格式] [--bitrate 192k] # 合并音频 node audio-forge.js merge <文件1> <文件2> [...] <输出> # 分割音频 node audio-forge.js split <输入> <开始时间> <结束时间> # 压缩音频 node audio-forge.js compress <输入> [--quality low|medium|high] # 查看音频信息 node audio-forge.js info <音频文件> # 音频转文字（Whisper） python transcribe.py <音频/视频文件> [--model small] [--language zh] [--format txt] [--device auto] ``` ## 音频转文字 - Whisper 模型 | 模型 | 精度 | 速度 | 首次下载 | |------|------|------|----------| | tiny | 较低 | 最快 | ~75MB | | base | 标准 | 快 | ~150MB | | small | 良好 | 较快 | ~460MB | | medium | 很好 | 较慢 | ~1.5GB | | large | 最高 | 最慢 | ~3GB | ## 安装 ```bash openclaw skill install xia-zhuan-audio ``` 或直接把 `xia-zhuan-audio` 文件夹放入 `~/.openclaw/workspace/skills/` 目录。 --- 创建：2026-04-11 | 作者：@luis12123899

path: audio-forge.js
content: #!/usr/bin/env node
/**
 * xia-zhuan-audio - 音频格式转换与处理工具
 * 基于 FFmpeg，支持格式转换、视频提取音频、合并、分割、压缩
 *
 * 环境变量配置（可选，不设置则自动查找）：
 *   XZA_FFMPEG   - FFmpeg/FFprobe 路径
 *   XZA_SCRIPTDIR - 技能脚本目录（包含 um.exe 和 transcribe.py）
 *
 * 自动查找顺序：
 *   1. 环境变量指定的路径
 *   2. 系统 PATH 中的 ffmpeg/ffprobe
 *   3. winget 安装的路径
 */

const { execSync, spawn, execFile } = require("child_process");
const path = require("path");
const fs = require("fs");

// ---------- 路径配置 ----------
function findFFmpeg() {
  // 1. 环境变量
  if (process.env.XZA_FFMPEG) return process.env.XZA_FFMPEG;

  // 2. 系统 PATH
  const sys = require("child_process");
  try {
    const out = sys.execSync("where ffmpeg", { encoding: "utf8", shell: true }).trim().split("\n")[0];
    if (out && fs.existsSync(out)) return out;
  } catch {}

}

function findFFprobe() {
  if (process.env.XZA_FFPROBE) return process.env.XZA_FFPROBE;
  if (process.env.XZA_FFMPEG) {
    const fp = process.env.XZA_FFMPEG.replace("ffmpeg.exe", "ffprobe.exe");
    if (fs.existsSync(fp)) return fp;
  }
  const ff = findFFmpeg();
  return ff.replace("ffmpeg.exe", "ffprobe.exe");
}

function getScriptDir() {
  // 优先用环境变量
  if (process.env.XZA_SCRIPTDIR) return process.env.XZA_SCRIPTDIR;
  // 默认：取当前脚本所在目录
  return __dirname;
}

const FFMPEG = findFFmpeg();
const FFPROBE = findFFprobe();
const SCRIPT_DIR = getScriptDir();
const UM = path.join(SCRIPT_DIR, "um.exe");

// ---------- 辅助函数 ----------
function getDuration(file) {
  try {
    const info = execFile(FFPROBE, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", "--", file], { encoding: "utf8" });
    return parseFloat(info.trim());
  } catch {
    return null;
  }
}

function formatTime(seconds) {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}

function runFFmpeg(args) {
  // Strip extra quotes from args (added by callers for shell safety) since spawn passes them literally
  const cleanArgs = args.map(a => typeof a === 'string' && a.startsWith('"') && a.endsWith('"') ? a.slice(1, -1) : a);
  const { spawn } = require("child_process");
  console.log("  执行: ffmpeg -y " + cleanArgs.join(" "));
  spawn(FFMPEG, ["-y", ...cleanArgs], { stdio: "inherit" }).on("close", (code) => {
    if (code !== 0) console.log("  ffmpeg 错误，退出码:", code);
  });
}

function ensureExtension(file, ext) {
  return file.replace(/\.[^.]+$/, "") + "." + ext;
}

function formatBytes(bytes) {
  if (bytes < 1024) return bytes + " B";
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
  return (bytes / (1024 * 1024)).toFixed(2) + " MB";
}

// ---------- 命令处理 ----------

/**
 * 格式转换
 * node audio-forge.js convert <input> <output-format> [--bitrate 192k]
 */
function cmdConvert(input, outputFormat, options = {}) {
  const output = options.output || ensureExtension(input, outputFormat);
  const bitrate = options.bitrate || "192k";

  console.log(`\n  音频格式转换`);
  console.log(`  输入: ${input}`);
  console.log(`  输出: ${output}`);
  console.log(`  目标格式: ${outputFormat.toUpperCase()}`);
  console.log(`  码率: ${bitrate}`);

  const args = [
    "-i", `"${input}"`,
    "-vn",
    "-ar", "44100",
    "-ac", "2",
    "-b:a", bitrate
  ];

  if (outputFormat === "mp3") {
    args.push("-id3v2_version", "3", `"${output}"`);
  } else if (outputFormat === "aac" || outputFormat === "m4a") {
    args.push("-c:a", "aac", "-b:a", bitrate, `"${output}"`);
  } else if (outputFormat === "flac") {
    args.push("-c:a", "flac", `"${output}"`);
  } else if (outputFormat === "wav") {
    args.push("-c:a", "pcm_s16le", `"${output}"`);
  } else {
    args[args.length - 1] = `"${output}"`;
  }

  runFFmpeg(args);
  console.log(`\n  转换完成: ${output}`);
}

/**
 * 从视频提取音频
 * node audio-forge.js extract <video> [output-format] [--bitrate 192k]
 */
function cmdExtract(video, outputFormat = "mp3", options = {}) {
  const output = options.output || ensureExtension(video, outputFormat);
  const bitrate = options.bitrate || "192k";

  console.log(`\n  视频提取音频`);
  console.log(`  视频: ${video}`);
  console.log(`  音频: ${output}`);

  const args = [
    "-i", `"${video}"`,
    "-vn",
    "-ac", "2",
    "-ar", "44100",
    "-b:a", bitrate
  ];

  if (outputFormat === "mp3") {
    args.push("-id3v2_version", "3", `"${output}"`);
  } else if (outputFormat === "aac" || outputFormat === "m4a") {
    args.push("-c:a", "aac", `"${output}"`);
  } else if (outputFormat === "wav") {
    args.push("-c:a", "pcm_s16le", `"${output}"`);
  } else {
    args.push(`"${output}"`);
  }

  runFFmpeg(args);
  console.log(`\n  提取完成: ${output}`);
}

/**
 * 合并多个音频文件
 * node audio-forge.js merge <file1> <file2> [...] <output>
 */
function cmdMerge(files, output) {
  const listFile = path.join(process.env.TEMP || "/tmp", "xza_merge_list.txt");
  console.log(`\n  音频合并`);
  console.log(`  文件数量: ${files.length}`);

  const content = files.map(f => `file '${f.replace(/\\/g, "/")}'`).join("\n");
  fs.writeFileSync(listFile, content, "utf8");

  const args = [
    "-f", "concat",
    "-safe", "0",
    "-i", `"${listFile}"`,
    "-c", "copy",
    `"${output}"`
  ];

  runFFmpeg(args);
  fs.unlinkSync(listFile);
  console.log(`\n  合并完成: ${output}`);
}

/**
 * 分割音频
 * node audio-forge.js split <input> <start> <end> [--output <file>]
 */
function cmdSplit(input, start, end, output) {
  const out = output || ensureExtension(input, "mp3");
  console.log(`\n  音频分割`);
  console.log(`  输入: ${input}`);
  console.log(`  范围: ${start} → ${end}`);
  console.log(`  输出: ${out}`);

  const args = [
    "-i", `"${input}"`,
    "-ss", start,
    "-to", end,
    "-c", "copy",
    `"${out}"`
  ];

  runFFmpeg(args);
  console.log(`\n  分割完成: ${out}`);
}

/**
 * 压缩音频
 * node audio-forge.js compress <input> [--quality low|medium|high] [--output <file>]
 */
function cmdCompress(input, output, options = {}) {
  const out = output || ensureExtension(input, "mp3");
  const quality = options.quality || "medium";

  const qualityMap = {
    low:    { bitrate: "64k" },
    medium: { bitrate: "128k" },
    high:   { bitrate: "192k" }
  };
  const q = qualityMap[quality] || qualityMap.medium;

  console.log(`\n  音频压缩`);
  console.log(`  输入: ${input}`);
  console.log(`  输出: ${out}`);
  console.log(`  压缩级别: ${quality}`);
  console.log(`  输出码率: ${q.bitrate}`);

  const args = [
    "-i", `"${input}"`,
    "-vn",
    "-ac", "2",
    "-ar", "44100",
    "-b:a", q.bitrate,
    `"${out}"`
  ];

  runFFmpeg(args);

  const inputSize = fs.statSync(input).size;
  const outputSize = fs.statSync(out).size;
  const ratio = ((1 - outputSize / inputSize) * 100).toFixed(1);
  console.log(`\n  压缩率: ${ratio}% (${formatBytes(inputSize)} → ${formatBytes(outputSize)})`);
  console.log(`  压缩完成: ${out}`);
}

/**
 * 查看音频信息
 * node audio-forge.js info <file>
 */
function cmdInfo(file) {
  console.log(`\n  音频信息: ${file}`);
  try {
    const raw = execFile(FFPROBE, ["-v", "error", "-show_format", "-show_streams", "--", file], { encoding: "utf8" });
    console.log(parseAudioInfo(raw, file));
  } catch (e) {
    console.log("无法获取音频信息:", e.message);
  }
}

function parseAudioInfo(raw, filePath) {
  const lines = raw.split('\n').map(l => l.trim());
  const info = { streams: [], format: {} };
  let current = null;

  for (const line of lines) {
    if (line === '[STREAM]') { current = {}; info.streams.push(current); }
    else if (line === '[/STREAM]') { current = null; }
    else if (line === '[FORMAT]') { current = info.format; }
    else if (line === '[/FORMAT]') { current = null; }
    else if (current && line.includes('=')) {
      const [k, ...v] = line.split('=');
      current[k] = v.join('=');
    }
  }

  const stream = info.streams.find(s => s.codec_type === 'audio') || info.streams[0] || {};
  const fmt = info.format;
  const duration = parseFloat(fmt.duration || stream.duration || 0);
  const size = parseInt(fmt.size || 0);
  const bitrate = parseInt(fmt.bit_rate || stream.bit_rate || 0);

  const durStr = formatDuration(duration);
  const sizeStr = formatBytes(size);
  const brStr = bitrate >= 1000 ? Math.round(bitrate / 1000) + ' kbps' : bitrate + ' bps';
  const sampleStr = stream.sample_rate ? (parseInt(stream.sample_rate) / 1000) + ' kHz' : 'N/A';
  const channels = stream.channels === '1' ? '单声道' : stream.channels === '2' ? '立体声' : (stream.channels || 'N/A');
  const codec = stream.codec_name ? stream.codec_name.toUpperCase() : 'N/A';

  const name = filePath.split('\\').pop();
  let out = `\n  +-- 基本信息 ----------------------------------+`;
  out += `\n  | 文件名: ${name}`;
  out += `\n  +-----------------------------------------------+`;
  out += `\n  | 时长:   ${durStr}`;
  out += `\n  | 大小:   ${sizeStr}`;
  out += `\n  +-----------------------------------------------+`;
  out += `\n  | 编码:   ${codec}`;
  out += `\n  | 码率:   ${brStr}`;
  out += `\n  | 采样率: ${sampleStr}`;
  out += `\n  | 声道:   ${channels}`;
  out += `\n  +-----------------------------------------------+`;
  if (fmt.TAG_title) out += `\n  | 标题:   ${fmt.TAG_title}`;
  if (fmt.TAG_artist) out += `\n  | 艺术家: ${fmt.TAG_artist}`;
  if (fmt.TAG_album) out += `\n  | 专辑:   ${fmt.TAG_album}`;
  if (fmt.TAG_date) out += `\n  | 日期:   ${fmt.TAG_date}`;
  out += `\n  +-----------------------------------------------+`;
  return out;
}

function formatDuration(seconds) {
  if (!seconds || isNaN(seconds)) return 'N/A';
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  if (h > 0) return h + '小时 ' + m + '分 ' + s + '秒';
  if (m > 0) return m + '分 ' + s + '秒';
  return s + '秒';
}

// ---------- CLI 入口 ----------

const [,, cmd, ...args] = process.argv;

function showHelp() {
  console.log(`
xia-zhuan-audio - 音频格式转换与处理工具

用法:
  node audio-forge.js <命令> [参数]

命令:
  convert <输入文件> <输出格式> [选项]    格式转换
    支持格式: mp3 wav flac aac m4a ogg wma aiff opus
    例: audio-forge.js convert song.m4a mp3
        audio-forge.js convert song.m4a mp3 --bitrate 320k

  extract <视频文件> [输出格式]          从视频提取音频
    例: audio-forge.js extract video.mp4 mp3

  merge <文件1> <文件2> [...] <输出>     合并多个音频
    例: audio-forge.js merge a.mp3 b.mp3 c.mp3 output.mp3

  split <输入文件> <开始时间> <结束时间> [选项]
                                            分割音频
    例: audio-forge.js split song.mp3 00:01:00 00:02:30

  compress <输入文件> [选项]               压缩音频
    例: audio-forge.js compress large.mp3 --quality high

  info <音频文件>                        查看音频信息

环境变量:
  XZA_FFMPEG    FFmpeg/FFprobe 路径（不设置则自动查找）
  XZA_FFPROBE   FFprobe 路径（默认从 XZA_FFMPEG 推断）
  XZA_SCRIPTDIR 脚本所在目录（包含 um.exe 和 transcribe.py）
`);
}

if (!cmd || cmd === "help" || cmd === "--help") {
  showHelp();
  process.exit(0);
}

(async () => {
  try {
    switch (cmd) {
      case "convert": {
        const input = args[0];
        const format = args[1];
        const opts = parseArgs(args.slice(2));
        if (!input || !format) { console.log("缺少参数"); showHelp(); process.exit(1); }
        cmdConvert(input, format, opts);
        break;
      }
      case "extract": {
        const input = args[0];
        const format = args[1] || "mp3";
        const opts = parseArgs(args.slice(2));
        if (!input) { console.log("缺少参数"); showHelp(); process.exit(1); }
        cmdExtract(input, format, opts);
        break;
      }
      case "merge": {
        if (args.length < 3) { console.log("合并至少需要2个文件"); showHelp(); process.exit(1); }
        const output = args[args.length - 1];
        const files = args.slice(0, -1);
        cmdMerge(files, output);
        break;
      }
      case "split": {
        const [input, start, end, ...rest] = args;
        const opts = parseArgs(rest);
        if (!input || !start || !end) { console.log("缺少参数"); showHelp(); process.exit(1); }
        cmdSplit(input, start, end, opts.output || null);
        break;
      }
      case "compress": {
        const [input, ...rest] = args;
        const opts = parseArgs(rest);
        if (!input) { console.log("缺少参数"); showHelp(); process.exit(1); }
        cmdCompress(input, opts.output || null, opts);
        break;
      }
      case "info": {
        const input = args[0];
        if (!input) { console.log("缺少参数"); showHelp(); process.exit(1); }
        cmdInfo(input);
        break;
      }
      default:
        console.log(`未知命令: ${cmd}`);
        showHelp();
        process.exit(1);
    }
  } catch (err) {
    console.error(`\n错误: ${err.message}`);
    process.exit(1);
  }
})();

function parseArgs(args) {
  const opts = {};
  for (let i = 0; i < args.length; i++) {
    if (args[i] === "--output" && args[i + 1]) {
      opts.output = args[++i];
    } else if (args[i] === "--bitrate" && args[i + 1]) {
      opts.bitrate = args[++i];
    } else if (args[i] === "--quality" && args[i + 1]) {
      opts.quality = args[++i];
    }
  }
  return opts;
}


path: menu.js
content: #!/usr/bin/env node
/**
 * xia-zhuan-audio 交互菜单 - 美化版
 */
const readline = require("readline");
const { spawn } = require("child_process");
const path = require("path");
const fs = require("fs");

const SCRIPT = path.join(__dirname, "audio-forge.js");

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

let menuColorIdx = 0;
const menuColors = ["\x1b[36m", "\x1b[33m", "\x1b[35m", "\x1b[32m"]; // 轮流颜色

function prompt(question) {
  return new Promise((resolve) => {
    rl.question(question, (answer) => resolve(answer));
  });
}

function clear() {
  console.clear();
}

function sp(n) { return " ".repeat(n); }
function line(w = 50, ch = "─") { return ch.repeat(w); }
function pad(s, len, ch = " ") { return String(s).padEnd(len, ch); }
function padc(s, len, ch = " ") {
  const p = len - String(s).length;
  return p > 0 ? ch.repeat(Math.floor(p/2)) + s + ch.repeat(Math.ceil(p/2)) : s;
}

function printBanner() {
  const w = 50;
  console.log("");
  console.log("\x1b[1m\x1b[36m  ╔" + line(w, "═") + "╗\x1b[0m");
  console.log("\x1b[1m\x1b[36m  ║\x1b[0m" + padc("🎵 xia-zhuan-audio 音频工具箱", w) + "\x1b[1m\x1b[36m║\x1b[0m");
  console.log("\x1b[1m\x1b[36m  ╠" + line(w, "═") + "╣\x1b[0m");
  console.log("\x1b[1m\x1b[36m  ║\x1b[0m" + padc("v1.2  |  FFmpeg + Whisper AI", w) + "\x1b[1m\x1b[36m║\x1b[0m");
  console.log("\x1b[1m\x1b[36m  ╚" + line(w, "═") + "╝\x1b[0m");
  console.log("");
}

function printSection(title, items) {
  const w = 50;
  console.log("\x1b[1m\x1b[33m  ┌" + line(w, "─") + "┐\x1b[0m");
  console.log("\x1b[1m\x1b[33m  │ [0m" + padc(title, w) + "\x1b[1m\x1b[33m│\x1b[0m");
  console.log("\x1b[1m\x1b[33m  └" + line(w, "─") + "┘\x1b[0m");
  for (const [key, label, desc] of items) {
    const col = menuColors[menuColorIdx++ % menuColors.length];
    console.log(`  ${col}[${key}]\x1b[0m ${label}  \x1b[90m${desc}\x1b[0m`);
  }
}

function printFooter() {
  const w = 50;
  console.log("");
  console.log("\x1b[90m  ╔" + line(w, "─") + "╗\x1b[0m");
  console.log("\x1b[90m  │\x1b[0m" + padc("输入 Q 退出", w) + "\x1b[90m│\x1b[0m");
  console.log("\x1b[90m  ╚" + line(w, "─") + "╝\x1b[0m");
  console.log("");
}

async function runFF(args) {
  return new Promise((resolve) => {
    const child = spawn("node", [SCRIPT, ...args], { stdio: "inherit" });
    child.on("close", (code) => resolve(code));
  });
}


async function menu() {
  while (true) {
    clear();
    printBanner();
    printSection("  音视频转换", [
      ["1", "格式转换",       "m4a→mp3, wav→flac, ape→flac"],
      ["2", "视频提取音频",   "mp4/mkv/avi → mp3/aac"],
      ["3", "合并多个音频",   "将多个音频拼接成一个"],
      ["4", "分割音频",       "按时间段截取音频片段"],
      ["5", "压缩音频",       "减小文件体积"],
    ]);
    printSection("  高级功能", [
      ["6", "查看音频信息",   "时长/码率/采样率/声道等"],
      ["7", "音频转文字",     "Whisper AI 自动转录，支持 txt/srt/vtt/json"],
    ]);
    printFooter();

    menuColorIdx = 0;
    const choice = await prompt("  请选择功能 [1-8, Q]: ");

    if (choice.toUpperCase() === "Q") {
      clear();
      console.log("\n  \x1b[32m再见！音频工具箱退出中...\x1b[0m\n");
      rl.close();
      process.exit(0);
    }

    switch (choice) {
      case "1": await cmdConvert(); break;
      case "2": await cmdExtract(); break;
      case "3": await cmdMerge(); break;
      case "4": await cmdSplit(); break;
      case "5": await cmdCompress(); break;
      case "6": await cmdInfo(); break;
      case "7": await cmdTranscribe(); break;
      default:
        console.log("\n  \x1b[31m无效选项，请重新选择\x1b[0m");
        await sleep(1200);
    }
  }
}

async function cmdConvert() {
  clear();
  console.log("\n  \x1b[1m\x1b[36m── 格式转换 ──\x1b[0m\n");
  console.log("  \x1b[90m支持: mp3 wav flac aac m4a ogg wma aiff opus\x1b[0m\n");

  const input = await prompt("  📂 请输入源文件路径: ");
  if (!input.trim()) return;

  const format = await prompt("  🎯 请输入目标格式 (如 mp3): ");
  if (!format.trim()) return;

  const bitrate = await prompt("  📊 码率 (直接回车默认 192k): ");

  console.log("");
  const args = ["convert", input.trim(), format.trim()];
  if (bitrate.trim()) args.push("--bitrate", bitrate.trim());
  await runFF(args);
  await prompt("\n  按回车继续...");
}

async function cmdExtract() {
  clear();
  console.log("\n  \x1b[1m\x1b[36m── 视频提取音频 ──\x1b[0m\n");

  const input = await prompt("  🎬 请输入视频文件路径: ");
  if (!input.trim()) return;

  const format = await prompt("  🎯 输出格式 (直接回车默认 mp3): ") || "mp3";
  const bitrate = await prompt("  📊 码率 (直接回车默认 192k): ");

  console.log("");
  const args = ["extract", input.trim(), format.trim()];
  if (bitrate.trim()) args.push("--bitrate", bitrate.trim());
  await runFF(args);
  await prompt("\n  按回车继续...");
}

async function cmdMerge() {
  clear();
  console.log("\n  \x1b[1m\x1b[36m── 合并多个音频 ──\x1b[0m\n");

  const countStr = await prompt("  🔢 要合并几个文件?: ");
  const count = parseInt(countStr);
  if (isNaN(count) || count < 2) {
    console.log("\n  \x1b[31m至少需要2个文件\x1b[0m");
    await sleep(1500);
    return;
  }

  const files = [];
  for (let i = 1; i <= count; i++) {
    const f = await prompt(`  📁 文件 ${i}: `);
    if (f.trim()) files.push(f.trim());
  }

  const output = await prompt("  💾 输出文件名 (如 result.mp3): ");
  if (!output.trim()) return;

  console.log("");
  await runFF(["merge", ...files, output.trim()]);
  await prompt("\n  按回车继续...");
}

async function cmdSplit() {
  clear();
  console.log("\n  \x1b[1m\x1b[36m── 分割音频 ──\x1b[0m\n");
  console.log("  \x1b[90m时间格式: HH:MM:SS 或 秒数，如 00:01:30\x1b[0m\n");

  const input = await prompt("  📂 输入文件: ");
  if (!input.trim()) return;

  const start = await prompt("  ⏱  开始时间 (如 00:01:00): ");
  const end = await prompt("  ⏱  结束时间 (如 00:02:30): ");
  if (!start.trim() || !end.trim()) return;

  const output = await prompt("  💾 输出文件 (直接回车自动命名): ");

  console.log("");
  const args = ["split", input.trim(), start.trim(), end.trim()];
  if (output.trim()) args.push("--output", output.trim());
  await runFF(args);
  await prompt("\n  按回车继续...");
}

async function cmdCompress() {
  clear();
  console.log("\n  \x1b[1m\x1b[36m── 压缩音频 ──\x1b[0m\n");
  console.log("  \x1b[32mlow   = 64kbps\x1b[0m   体积小，音质较差");
  console.log("  \x1b[33mmedium = 128kbps\x1b[0m  平衡（默认）");
  console.log("  \x1b[35mhigh  = 192kbps\x1b[0m  体积大，音质好\n");

  const level = await prompt("  选择级别 [low/medium/high，直接回车默认 medium]: ") || "medium";
  const input = await prompt("  📂 输入文件: ");
  if (!input.trim()) return;

  const output = await prompt("  💾 输出文件 (直接回车自动命名): ");

  console.log("");
  const args = ["compress", input.trim()];
  if (output.trim()) args.push("--output", output.trim());
  args.push("--quality", level.trim());
  await runFF(args);
  await prompt("\n  按回车继续...");
}

async function cmdInfo() {
  clear();
  console.log("\n  \x1b[1m\x1b[36m── 查看音频信息 ──\x1b[0m\n");

  const input = await prompt("  📂 输入音频文件: ");
  if (!input.trim()) return;

  console.log("");
  await runFF(["info", input.trim()]);
  await prompt("\n  按回车继续...");
}

async function cmdTranscribe() {
  clear();
  console.log("\n  \x1b[1m\x1b[36m── 音频转文字 (Whisper AI) ──\x1b[0m\n");
  console.log("  \x1b[90m模型: tiny(最快) / base / small(默认) / medium / large(最慢)\x1b[0m");
  console.log("  \x1b[90m语言: zh(中文) / en(英文) / ja(日文) / auto(自动检测)\x1b[0m");
  console.log("  \x1b[90m格式: txt/srt/vtt/json | 语言: zh/en/auto | 设备: auto/cpu/cuda\x1b[0m\n");

  const input = await prompt("  🎤 请输入音频文件路径: ");
  if (!input.trim()) return;

  const model = await prompt("  🤖 选择模型 (直接回车默认 small): ") || "small";
  const language = await prompt("  🌐 选择语言 (直接回车默认 auto): ") || "auto";
  const format = await prompt("  📝 输出格式 txt/srt/vtt/json (直接回车默认 txt): ") || "txt";

  console.log("\n  开始转录...\n");
  return new Promise((resolve) => {
    const { spawn } = require("child_process");
    const child = spawn("python", [
      "-u",
      path.join(__dirname, "transcribe.py"),
      input.trim(),
      "-m", model.trim(),
      "-l", language.trim(),
      "-f", format.trim(),
    ], { stdio: "inherit" });

    child.on("close", async (code) => {
      if (code === 0) {
        console.log("\n  \x1b[32m✅ 转录完成!\x1b[0m");
      } else {
        console.log("\n  \x1b[31m❌ 转录失败!\x1b[0m");
      }
      await prompt("  按回车继续...");
      resolve();
    });
  });
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

menu().catch((err) => {
  console.error("\n  \x1b[31m错误:\x1b[0m", err.message);
  process.exit(1);
});


path: package.json
content: {
  "name": "xia-zhuan-audio",
  "version": "1.3.0",
  "description": "Audio conversion & transcription tool based on FFmpeg + Whisper AI (um.exe removed)",
  "keywords": [
    "audio",
    "ffmpeg",
    "whisper",
    "transcription",
    "conversion"
  ],
  "dependencies": {},
  "optionalDependencies": {}
}

path: transcribe.py
content: #!/usr/bin/env python3
"""
xia-zhuan-audio 转录功能 - 基于 faster-whisper
自动下载 Whisper 模型，首次运行会下载约几十MB
"""
import sys
import os

os.environ.setdefault("HF_ENDPOINT", "https://huggingface.co")
os.environ["PYTHONIOENCODING"] = "utf-8"

import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
import argparse
import json

def format_timestamp(seconds, fmt="vtt"):
    """将秒数转换为指定格式的时间戳"""
    if seconds is None:
        return "00:00:00,000" if fmt == "srt" else "00:00:00.000"
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = int(seconds % 60)
    ms = int((seconds % 1) * 1000)
    if fmt == "srt":
        return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
    return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"

def format_duration(seconds):
    """将秒数转换为中文时长描述"""
    if seconds is None:
        return "N/A"
    h = int(seconds // 3600)
    m = int((seconds % 3600) // 60)
    s = int(seconds % 60)
    if h > 0:
        return f"{h}小时{m}分{s}秒"
    if m > 0:
        return f"{m}分{s}秒"
    return f"{s}秒"

def detect_audio_video(input_path):
    """检查输入是音频还是视频文件"""
    video_exts = {".mp4", ".mkv", ".avi", ".mov", ".flv", ".wmv", ".webm", ".m4v"}
    ext = os.path.splitext(input_path)[1].lower()
    return ext in video_exts

def extract_audio(input_path, keep_audio=False):
    """从视频提取音频（临时文件）或原样返回"""
    import subprocess
    audio_exts = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".wma", ".aiff"}
    ext = os.path.splitext(input_path)[1].lower()
    if ext in audio_exts:
        return input_path, None  # 本身就是音频，无需处理

    # 需要从视频提取音频
    temp_audio = os.path.splitext(input_path)[0] + "_temp_audio.mp3"
    cmd = [
        "ffmpeg", "-y", "-i", input_path,
        "-vn", "-acodec", "libmp3lame", "-q:a", "2",
        temp_audio
    ]
    print(f"  从视频提取音频...")
    try:
        subprocess.run(cmd, capture_output=True, check=True)
    except subprocess.CalledProcessError as e:
        print(f"[错误] 提取音频失败: {e.stderr.decode('utf-8', errors='replace')}")
        sys.exit(1)
    return temp_audio, temp_audio if not keep_audio else None

def main():
    parser = argparse.ArgumentParser(
        description="xia-zhuan-audio 转录 - 音视频转文字（基于 Whisper AI）",
        formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("input", help="输入音频或视频文件路径")
    parser.add_argument("--output", "-o", help="输出文件路径，默认同目录同名")
    parser.add_argument("--model", "-m", default="small",
                        choices=["tiny", "base", "small", "medium", "large"],
                        help="Whisper 模型: tiny/base/small/medium/large，默认 small")
    parser.add_argument("--language", "-l", default="auto",
                        help="语言代码，如 zh/en/ja/fr/de 等，默认 auto 自动检测")
    parser.add_argument("--format", "-f", default="txt",
                        choices=["txt", "srt", "vtt", "json"],
                        help="输出格式: txt(纯文本) srt(字幕) vtt(网页字幕) json(完整数据)，默认 txt")
    parser.add_argument("--task", "-t", default="transcribe",
                        choices=["transcribe", "translate"],
                        help="transcribe(转录) 或 translate(翻译为英文)，默认 transcribe")
    parser.add_argument("--device", "-d", default="auto",
                        choices=["auto", "cpu", "cuda"],
                        help="计算设备: auto/cpu/cuda，默认 auto")
    parser.add_argument("--keep-audio", "-k", action="store_true",
                        help="保留从视频提取的临时音频文件")

    args = parser.parse_args()

    if not os.path.exists(args.input):
        print(f"[错误] 文件不存在: {args.input}")
        sys.exit(1)

    # 确定输出路径
    if args.output:
        output_path = args.output
    else:
        ext_map = {"txt": ".txt", "srt": ".srt", "vtt": ".vtt", "json": ".json"}
        base = os.path.splitext(args.input)[0]
        output_path = base + ext_map[args.format]

    # 如果输入是视频，先提取音频
    temp_audio, temp_to_delete = extract_audio(args.input, args.keep_audio)
    actual_input = temp_audio

    print(f"\n🎤 音视频转文字")
    print(f"  输入文件: {args.input}")
    print(f"  输出文件: {output_path}")
    print(f"  模型: {args.model} | 语言: {args.language} | 格式: {args.format}")

    # 模型本地缓存目录
    model_dir = os.environ.get("XZA_MODELDIR") or os.path.join(
        os.path.dirname(os.path.abspath(__file__)), "whisper_models"
    )
    os.makedirs(model_dir, exist_ok=True)

    try:
        from faster_whisper import WhisperModel
    except ImportError:
        print("\n[错误] 未安装 faster-whisper，请运行:")
        print("  pip install faster-whisper")
        sys.exit(1)

    # 选择计算设备
    if args.device == "cuda":
        try:
            import torch
            if torch.cuda.is_available():
                device = "cuda"
                compute_type = "float16"
            else:
                print("  [警告] 未检测到 CUDA，切换为 CPU")
                device = "cpu"
                compute_type = "int8"
        except ImportError:
            print("  [警告] 未安装 PyTorch CUDA 版本，切换为 CPU")
            device = "cpu"
            compute_type = "int8"
    elif args.device == "cpu":
        device = "cpu"
        compute_type = "int8"
    else:
        # auto 模式
        try:
            import torch
            if torch.cuda.is_available():
                device = "cuda"
                compute_type = "float16"
            else:
                device = "cpu"
                compute_type = "int8"
        except ImportError:
            device = "cpu"
            compute_type = "int8"

    device_label = "GPU (CUDA)" if device == "cuda" else "CPU"
    print(f"  设备: {device_label}")

    print(f"\n  首次使用将自动下载 Whisper {args.model} 模型...")
    print(f"  模型保存位置: {model_dir}")

    try:
        model = WhisperModel(
            args.model,
            device=device,
            compute_type=compute_type,
            download_root=model_dir,
        )

        language = None if args.language == "auto" else args.language

        print(f"\n  开始转录，请稍候...\n")

        segments_gen, info = model.transcribe(
            actual_input,
            language=language,
            task=args.task,
            beam_size=5,
            vad_filter=True,
        )

        if language is None:
            print(f"  检测语言: {info.language} | 时长: {format_duration(info.duration)}")
        else:
            if info.language != language:
                print(f"  [注意] 检测到语言: {info.language}，与指定语言 {language} 不一致\n")
            print(f"  时长: {format_duration(info.duration)}")

        # 将生成器转为列表（可迭代多次）
        segments_list = list(segments_gen)
        total = len(segments_list)
        duration = info.duration or 0

        if args.format == "txt":
            lines = []
            for i, seg in enumerate(segments_list, 1):
                text = seg.text.strip()
                lines.append(text)
                print(f"\r  进度: {i}/{total} 片段", end="", flush=True)
            with open(output_path, "w", encoding="utf-8") as f:
                f.write("\n".join(lines) + "\n")

        elif args.format == "srt":
            with open(output_path, "w", encoding="utf-8") as f:
                for i, seg in enumerate(segments_list, 1):
                    start = format_timestamp(seg.start, "srt")
                    end = format_timestamp(seg.end, "srt")
                    text = seg.text.strip()
                    f.write(f"{i}\n{start} --> {end}\n{text}\n\n")
                    print(f"\r  进度: {i}/{total} 片段", end="", flush=True)
            print("")

        elif args.format == "vtt":
            with open(output_path, "w", encoding="utf-8") as f:
                f.write("WEBVTT\n\n")
                for seg in segments_list:
                    start = format_timestamp(seg.start, "vtt")
                    end = format_timestamp(seg.end, "vtt")
                    text = seg.text.strip()
                    f.write(f"{start} --> {end}\n{text}\n\n")
                    print(f"\r  进度: {segments_list.index(seg)+1}/{total} 片段", end="", flush=True)
            print("")

        elif args.format == "json":
            output_data = {
                "model": args.model,
                "language": info.language,
                "duration": duration,
                "device": device_label,
                "task": args.task,
                "segments_count": total,
                "segments": []
            }
            for seg in segments_list:
                output_data["segments"].append({
                    "id": len(output_data["segments"]),
                    "start": round(seg.start, 3),
                    "end": round(seg.end, 3),
                    "text": seg.text.strip(),
                    "words": [
                        {
                            "word": w.word,
                            "start": round(w.start, 3),
                            "end": round(w.end, 3),
                            "probability": round(w.probability, 3)
                        } for w in (seg.words or [])
                    ]
                })
            with open(output_path, "w", encoding="utf-8") as f:
                json.dump(output_data, f, ensure_ascii=False, indent=2)
            print(f"  已保存 {total} 个片段的完整数据")

        print(f"\n\n  ✅ 转录完成!")
        print(f"  已保存到: {output_path}")

        # 统计信息
        with open(output_path, "r", encoding="utf-8") as f:
            content = f.read()
        if args.format != "json":
            char_count = len(content.replace("\n", "").replace(" ", ""))
            word_count = len(content.split())
            line_count = len(content.splitlines())
            print(f"  字符数: {char_count} | 词数: {word_count} | 片段数: {total}")
        else:
            print(f"  片段数: {total}")

    except Exception as e:
        print(f"\n[错误] 转录失败: {e}")
        import traceback
        traceback.print_exc()
        sys.exit(1)
    finally:
        # 清理临时音频文件
        if temp_to_delete and os.path.exists(temp_to_delete):
            try:
                os.remove(temp_to_delete)
            except:
                pass

if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)