Skill: robotsbuildrobots/mai-transcribe (version 0.1.1)

--- name: mai-transcribe description: Transcribe audio with Microsoft's MAI-Transcribe-1 model via Azure AI Speech. homepage: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/mai-transcribe metadata: {"clawdbot":{"emoji":"🪟","requires":{"bins":["node"],"env":["AZURE_SPEECH_ENDPOINT","AZURE_SPEECH_KEY"]},"primaryEnv":"AZURE_SPEECH_KEY"}} --- # MAI-Transcribe-1 Transcribe an audio file via Azure AI Speech using Microsoft's **MAI-Transcribe-1** model. ## Quick start ```bash node {baseDir}/scripts/transcribe.js /path/to/audio.m4a ``` Defaults: - Model: `mai-transcribe-1` - Output: `<input>.txt` - API version: `2025-10-15` ## Useful flags ```bash node {baseDir}/scripts/transcribe.js /path/to/audio.ogg --out /tmp/transcript.txt node {baseDir}/scripts/transcribe.js /path/to/audio.m4a --language en-GB node {baseDir}/scripts/transcribe.js /path/to/audio.m4a --json --out /tmp/transcript.json node {baseDir}/scripts/transcribe.js /path/to/audio.wav --model mai-transcribe-1 node {baseDir}/scripts/transcribe.js --help ``` ## Required env vars ```bash export AZURE_SPEECH_ENDPOINT="https://YOUR-RESOURCE.cognitiveservices.azure.com" export AZURE_SPEECH_KEY="YOUR_SPEECH_RESOURCE_KEY" ``` ## How to get the API key 1. Go to the Azure portal and open your **Speech** or **Foundry Speech** resource. 2. Open **Keys and Endpoint**. 3. Copy: - the resource endpoint, for example `https://your-resource.cognitiveservices.azure.com` - one of the resource keys 4. Export them: ```bash export AZURE_SPEECH_ENDPOINT="https://YOUR-RESOURCE.cognitiveservices.azure.com" export AZURE_SPEECH_KEY="YOUR_SPEECH_RESOURCE_KEY" ``` If `gh`-style copy-paste chaos is happening, the most important bit is that this skill expects the **Speech resource endpoint**, not a generic Foundry project URL. Optional: ```bash export AZURE_SPEECH_API_VERSION="2025-10-15" ``` ## API shape The script calls: ```text POST {AZURE_SPEECH_ENDPOINT}/speechtotext/transcriptions:transcribe?api-version=2025-10-15 ``` Headers: - `Ocp-Apim-Subscription-Key: {AZURE_SPEECH_KEY}` Multipart form fields: - `audio` - `definition` Example `definition` payload: ```json { "enhancedMode": { "enabled": true, "model": "mai-transcribe-1" } } ``` ## Notes - This is the same style of skill as the Whisper one: a small documented script wrapper, not a built-in OpenClaw media pipeline. - Tested successfully against a live Azure Speech resource. - `--json` writes the raw Azure response for debugging or downstream processing. - Audio is uploaded to Microsoft for processing.

path: README.md
content: # mai-transcribe

Transcribe audio with Microsoft's **MAI-Transcribe-1** model via **Azure AI Speech**.

## Status

Working, and actually tested, which is refreshing.

Confirmed against a live Azure Speech resource using the documented endpoint:

```text
POST /speechtotext/transcriptions:transcribe?api-version=2025-10-15
```

## Files

- `scripts/transcribe.js` — transcription CLI
- `scripts/common.js` — env, validation, extraction, and error helpers
- `_meta.json` — package metadata for publishing

## Required environment variables

```bash
export AZURE_SPEECH_ENDPOINT="https://YOUR-RESOURCE.cognitiveservices.azure.com"
export AZURE_SPEECH_KEY="YOUR_SPEECH_RESOURCE_KEY"
```

## How to get the API key

1. Open the Azure portal.
2. Find your **Speech** or **Foundry Speech** resource.
3. Open **Keys and Endpoint**.
4. Copy the endpoint and one of the keys.
5. Export them as `AZURE_SPEECH_ENDPOINT` and `AZURE_SPEECH_KEY`.

Use the Speech resource endpoint, for example:

```bash
export AZURE_SPEECH_ENDPOINT="https://your-resource.cognitiveservices.azure.com"
```

Do not use a random Foundry project URL here, unless you enjoy debugging 404s for sport.

Optional:

```bash
export AZURE_SPEECH_API_VERSION="2025-10-15"
```

## Examples

```bash
node scripts/transcribe.js /path/to/audio.wav
node scripts/transcribe.js /path/to/audio.ogg --out /tmp/transcript.txt
node scripts/transcribe.js /path/to/audio.m4a --json --out /tmp/transcript.json
node scripts/transcribe.js /path/to/audio.wav --language en-GB
node scripts/transcribe.js /path/to/audio.wav --help
```

## Output behavior

- default output is plain text
- default output path is `<input>.txt`
- `--json` writes the raw Azure response instead

## Caveats

- The script currently buffers the whole audio file into memory before upload. Fine for normal voice notes, less cute for giant recordings.
- This package is intentionally **transcription-only**. Voice and image were removed until they have real, confirmed endpoints.
- Audio is uploaded to Microsoft for processing. Don't send sensitive material unless you're comfortable with that.

## How this compares to the Whisper skill

This follows the same basic pattern as `openai-whisper-api`:

- a small script in `scripts/`
- a `SKILL.md` that tells the agent how to use it
- explicit environment variables
- no automatic Telegram or media-hook integration by itself

So if you want Whisper-like *agent behavior* in chat, that still depends on the agent deciding to run the script, or on adding a first-class OpenClaw tool/media integration later.

## Publish

Suggested publish command:

```bash
clawhub publish ./skills/mai-transcribe --slug mai-transcribe --name "MAI Transcribe" --version 0.1.0 --changelog "Initial release: Azure Speech MAI-Transcribe-1 transcription skill"
```


path: _meta.json
content: {
  "title": "MAI Transcribe",
  "slug": "mai-transcribe",
  "version": "0.1.0",
  "summary": "Transcribe audio with Microsoft's MAI-Transcribe-1 model via Azure AI Speech.",
  "license": "MIT",
  "keywords": [
    "azure",
    "speech",
    "transcription",
    "mai",
    "speech-to-text"
  ]
}


path: scripts/common.js
content: const fs = require('fs');
const path = require('path');

function getEnv(name, fallback) {
  return process.env[name] || fallback;
}

function requiredEnv(name) {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required env var: ${name}`);
  return value;
}

function apiBase() {
  return requiredEnv('AZURE_SPEECH_ENDPOINT').replace(/\/$/, '');
}

function apiVersion() {
  return getEnv('AZURE_SPEECH_API_VERSION', '2025-10-15');
}

function apiKey() {
  return requiredEnv('AZURE_SPEECH_KEY');
}

function parseArgs(argv) {
  const args = { _: [], __unknown: [] };
  for (let i = 0; i < argv.length; i++) {
    const cur = argv[i];
    if (!cur.startsWith('--')) {
      args._.push(cur);
      continue;
    }

    const eq = cur.indexOf('=');
    if (eq !== -1) {
      args[cur.slice(2, eq)] = cur.slice(eq + 1);
      continue;
    }

    const key = cur.slice(2);
    const next = argv[i + 1];
    if (!next || next.startsWith('--')) {
      args[key] = true;
      continue;
    }

    args[key] = next;
    i++;
  }
  return args;
}

function resolveAudioFile(input) {
  if (!input) throw new Error('Missing audio file path. Use --help for usage.');
  const filePath = path.resolve(input);
  if (!fs.existsSync(filePath)) throw new Error(`Audio file not found: ${filePath}`);
  const stat = fs.statSync(filePath);
  if (!stat.isFile()) throw new Error(`Audio path is not a file: ${filePath}`);
  return { filePath, stat };
}

function ensureParentDir(targetPath) {
  const dir = path.dirname(targetPath);
  fs.mkdirSync(dir, { recursive: true });
}

function extractTranscript(data) {
  const combined = Array.isArray(data?.combinedPhrases)
    ? data.combinedPhrases.map(x => x?.text).filter(Boolean).join('\n')
    : '';
  if (combined) return combined;

  const phrases = Array.isArray(data?.phrases)
    ? data.phrases.map(x => x?.text).filter(Boolean).join('\n')
    : '';
  if (phrases) return phrases;

  if (typeof data?.text === 'string' && data.text.trim()) return data.text;
  if (typeof data?.transcript === 'string' && data.transcript.trim()) return data.transcript;
  return '';
}

function formatApiError(res, data, url) {
  const code = data?.error?.code || data?.code || String(res.status);
  const message = data?.error?.message || data?.message || res.statusText;

  let hint = '';
  if (res.status === 401 || res.status === 403) {
    hint = 'Check AZURE_SPEECH_KEY and confirm the endpoint matches the Speech resource.';
  } else if (res.status === 404) {
    hint = 'The endpoint path or resource is wrong, or the resource does not support this API/version.';
  } else if (res.status === 429) {
    hint = 'Rate limited by Azure. Retry later or add backoff.';
  } else if (res.status >= 500) {
    hint = 'Azure had a server-side wobble. Retry may succeed.';
  }

  return new Error(
    [
      `Azure Speech transcription failed: HTTP ${res.status} ${res.statusText}`,
      `Code: ${code}`,
      `Message: ${message}`,
      hint && `Hint: ${hint}`,
      `URL: ${url}`,
    ].filter(Boolean).join('\n')
  );
}

module.exports = {
  apiBase,
  apiKey,
  apiVersion,
  ensureParentDir,
  extractTranscript,
  formatApiError,
  parseArgs,
  resolveAudioFile,
};


path: scripts/transcribe.js
content: #!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const {
  apiBase,
  apiKey,
  apiVersion,
  ensureParentDir,
  extractTranscript,
  formatApiError,
  parseArgs,
  resolveAudioFile,
} = require('./common');

const HELP = `MAI-Transcribe-1 via Azure Speech

Usage:
  node transcribe.js <audio-file> [options]

Options:
  --out <path>           Output file path (default: <input>.txt)
  --json                 Write raw JSON response instead of plain text
  --language <locale>    Locale hint, e.g. en-GB or en-US
  --model <name>         Model name (default: mai-transcribe-1)
  --api-version <ver>    API version (default: env or 2025-10-15)
  --help                 Show this help

Required env:
  AZURE_SPEECH_ENDPOINT  e.g. https://your-resource.cognitiveservices.azure.com
  AZURE_SPEECH_KEY       Azure Speech key
`;

async function main() {
  const args = parseArgs(process.argv.slice(2));
  if (args.help) {
    console.log(HELP);
    return;
  }

  validateArgs(args);

  const { filePath, stat } = resolveAudioFile(args._[0]);
  const outPath = path.resolve(args.out || `${filePath}${args.json ? '.json' : '.txt'}`);
  ensureParentDir(outPath);

  if (stat.size > 25 * 1024 * 1024) {
    console.error(`Warning: buffering a large audio file in memory (${Math.round(stat.size / (1024 * 1024))} MB).`);
  }

  const form = new FormData();
  form.append('audio', new Blob([fs.readFileSync(filePath)]), path.basename(filePath));
  form.append('definition', JSON.stringify(buildDefinition(args)));

  const url = `${apiBase()}/speechtotext/transcriptions:transcribe?api-version=${encodeURIComponent(args['api-version'] || apiVersion())}`;
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'Ocp-Apim-Subscription-Key': apiKey(),
    },
    body: form,
  });

  const raw = await res.text();
  let data;
  try {
    data = JSON.parse(raw);
  } catch {
    data = { raw };
  }

  if (!res.ok) throw formatApiError(res, data, url);

  fs.writeFileSync(
    outPath,
    args.json ? JSON.stringify(data, null, 2) : extractTranscript(data),
    'utf8'
  );

  console.log(outPath);
}

function buildDefinition(args) {
  const definition = {
    enhancedMode: {
      enabled: true,
      model: args.model || 'mai-transcribe-1',
    },
  };

  if (args.language) definition.locales = [args.language];
  return definition;
}

function validateArgs(args) {
  const allowed = new Set(['out', 'json', 'language', 'model', 'api-version', 'help', '_', '__unknown']);
  for (const key of Object.keys(args)) {
    if (!allowed.has(key)) {
      throw new Error(`Unknown option: --${key}\n\n${HELP}`);
    }
  }
}

main().catch(err => {
  console.error(err.stack || String(err));
  process.exit(1);
});


path: test/test.js
content: const assert = require('assert');
const { extractTranscript, parseArgs } = require('../scripts/common');

assert.equal(
  extractTranscript({ combinedPhrases: [{ text: 'hello' }, { text: 'world' }] }),
  'hello\nworld'
);

assert.equal(
  extractTranscript({ phrases: [{ text: 'fallback phrase' }] }),
  'fallback phrase'
);

assert.equal(
  extractTranscript({ text: 'plain text' }),
  'plain text'
);

assert.equal(
  extractTranscript({}),
  ''
);

const args = parseArgs(['file.wav', '--json', '--language=en-GB', '--out', '/tmp/x.txt']);
assert.equal(args._[0], 'file.wav');
assert.equal(args.json, true);
assert.equal(args.language, 'en-GB');
assert.equal(args.out, '/tmp/x.txt');

console.log('ok');


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