Skill: fourthdensity/sam-tts (version 1.0.0)

--- name: sam-tts description: Generate retro robotic speech audio using SAM (Software Automatic Mouth), the classic C64 text-to-speech synthesizer. Use for /sam command to generate voice messages. Supports /sam on/off toggle mode where all responses are spoken in SAM voice. Supports pitch, speed, mouth, and throat parameters for voice customization. homepage: https://github.com/discordier/sam metadata: { "openclaw": { "emoji": "🤖", "requires": { "bins": ["node"] }, "install": [ { "id": "npm-install", "kind": "node", "package": "sam-js", "bins": ["node"], "label": "Install SAM dependencies (npm install)", }, ], }, } --- # SAM TTS - Software Automatic Mouth Generate WAV audio files using the classic SAM text-to-speech engine -- the iconic robotic voice from the Commodore 64 era. ## Requirements - Node.js 18+ - Run `npm install` in the skill directory to install dependencies ## SAM Mode Toggle **State file:** `memory/sam-mode.json` ### `/sam on` -- Enable SAM Mode When SAM mode is enabled, ALL text responses are converted to SAM voice messages. **Implementation:** 1. Set `enabled: true` in `memory/sam-mode.json` 2. Confirm with voice message: "SAM mode enabled. I will now speak in robotic voice." ### `/sam off` -- Disable SAM Mode Return to normal text-to-text communication. **Implementation:** 1. Set `enabled: false` in `memory/sam-mode.json` 2. Confirm with text: "SAM mode disabled. Back to text." ### Check current mode Read `memory/sam-mode.json` at session start to know current state. ## Response Behavior ### When SAM mode is ON: 1. Generate response text as normal 2. Convert to SAM TTS: `node scripts/sam-tts-wrapper.js "response" --output=/tmp/sam-XXX.wav --quiet` 3. Send the generated WAV file as audio output 4. Include brief text caption if helpful ### When SAM mode is OFF: Respond with normal text (default behavior). ## Chat Commands ### `/sam <text>` Generate a one-time voice message using SAM TTS (works regardless of SAM mode state). **Implementation:** 1. Extract text after `/sam ` 2. Generate WAV: `node scripts/sam-tts-wrapper.js "text" --output=/tmp/sam-XXX.wav --quiet` 3. Return the WAV file as audio output ### `/sam on` Enable SAM mode for all responses. ### `/sam off` Disable SAM mode. ### `/sam status` Report current SAM mode state (text response). ## Voice Parameters All parameters accept 0-255 range values. Store defaults in `memory/sam-mode.json`: | Parameter | Default | Effect | |-----------|---------|--------| | `pitch` | 64 | Voice pitch (higher = higher pitch) | | `speed` | 72 | Speech speed (lower = faster) | | `mouth` | 128 | Mouth cavity size (affects resonance) | | `throat` | 128 | Throat size (affects timbre) | ### `/sam pitch <number>` Set pitch parameter (0-255). ### `/sam speed <number>` Set speed parameter (1-255, lower is faster). ### `/sam mouth <number>` Set mouth parameter (0-255). ### `/sam throat <number>` Set throat parameter (0-255). ## Scripts ### `scripts/sam-tts-wrapper.js` Primary wrapper script. Outputs JSON metadata for automation. ```bash node scripts/sam-tts-wrapper.js "Hello world" --output=/tmp/out.wav --quiet node scripts/sam-tts-wrapper.js "Hello world" --output=/tmp/out.wav --quiet --pitch=80 --speed=60 ``` **Options:** - `--output=PATH` (required) - Output WAV file path - `--quiet` - Suppress debug output, output only JSON - `--pitch=N`, `--speed=N`, `--mouth=N`, `--throat=N` - Voice parameters - `--phonetic` - Input is phonetic notation **Output format:** ```json {"success":true,"outputPath":"/tmp/sam.wav","duration":1.44,"size":31741} ``` ### `scripts/sam-tts.js` Standalone CLI tool with human-readable output. ```bash node scripts/sam-tts.js "Hello world" output.wav --pitch=80 --speed=60 ``` ## State Management ### File: `memory/sam-mode.json` ```json { "enabled": false, "pitch": 64, "speed": 72, "mouth": 128, "throat": 128 } ``` Read at session start. Update when user toggles mode or changes parameters. Create the `memory/` directory if it doesn't exist. ## Examples ### Enable SAM mode User: `/sam on` Agent: [Voice: "SAM mode enabled. I will now speak in robotic voice."] ### Normal conversation in SAM mode User: "What's the weather?" Agent: [Voice: "Current temperature is 72 degrees with partly cloudy skies."] ### Disable SAM mode User: `/sam off` Agent: SAM mode disabled. Back to text. ### One-time voice (even when mode is off) User: `/sam Hello there` Agent: [Voice: "Hello there"] ### Custom voice parameters User: `/sam pitch 100` Agent: Pitch set to 100. User: `/sam Testing higher pitch` Agent: [Voice with pitch=100: "Testing higher pitch"] ## Phonetic Notation For precise pronunciation, use `--phonetic` flag: - Vowels: `IY` (bee), `IH` (bit), `EY` (bay), `AE` (bat), `AA` (father), `AH` (bought), `AO` (hot), `OW` (boat), `UH` (book), `UW` (boot), `ER` (bird), `AX` (about) - Numbers 1-8 indicate stress: `HEH4LOW` (emphasized second syllable) See `references/phonemes.md` for the full phoneme chart. ## Output Format - **Format**: WAV (RIFF/WAVE PCM) - **Sample rate**: 22050 Hz - **Bit depth**: 8-bit - **Channels**: Mono ## Directory Structure ``` sam-tts/ +-- SKILL.md +-- package.json +-- scripts/ | +-- sam-tts-wrapper.js | +-- sam-tts.js +-- references/ | +-- phonemes.md +-- memory/ +-- sam-mode.json ``` ## Platform Integration The skill outputs WAV format by default. Some messaging platforms require different audio formats. ### Format Conversion (Optional) **(Warning):** Before installing ffmpeg or any conversion tools, the agent MUST ask the user for confirmation. Do not auto-install dependencies. Example agent prompt: > "This platform requires OGG/OPUS format. I need to install ffmpeg for audio conversion. Would you like me to proceed with the installation?" Only proceed with installation after explicit user approval. ### Telegram / WhatsApp (OGG/OPUS) ```bash ffmpeg -i input.wav -c:a libopus -b:a 24k output.ogg ``` ### Discord (MP3/OGG) ```bash ffmpeg -i input.wav -c:a libmp3lame -b:a 64k output.mp3 ``` ### Web / Direct Playback WAV works directly -- no conversion needed. **Note:** The agent should detect the target platform and handle format conversion as needed, but always with user consent for any new dependencies. ## Credits **Skill by:** [fourthdensity](https://github.com/fourthdensity) **Active Dependency:** [sam-js](https://github.com/discordier/sam) by discordier - The npm package used for TTS synthesis (JavaScript/Node.js port) **Historical Lineage:** sam-js builds upon earlier community ports: - [SAM by Stefan Macke](https://github.com/s-macke/SAM) (C adaptation) - [SAM by Vidar Hokstad](https://github.com/vidarh/SAM) (refactoring) - [SAM by 8BitPimp](https://github.com/8BitPimp/SAM) (refactoring) Original SAM (Software Automatic Mouth) (c) 1982 Don't Ask Software (now SoftVoice, Inc.) **License Note:** The original SAM software is considered abandonware. The JavaScript adaptation is provided as-is. See the sam-js repository for full license details.

path: package-lock.json
content: {
  "name": "sam-tts-skill",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "sam-tts-skill",
      "version": "1.0.0",
      "license": "MIT",
      "dependencies": {
        "sam-js": "^0.3.0"
      }
    },
    "node_modules/sam-js": {
      "version": "0.3.1",
      "resolved": "https://registry.npmjs.org/sam-js/-/sam-js-0.3.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "SEE LICENSE IN README.md",
      "engines": {
        "node": ">= 18.0.0",
        "yarn": ">= 1.22.15"
      }
    }
  }
}


path: package.json
content: {
  "name": "sam-tts-skill",
  "version": "1.0.0",
  "description": "SAM (Software Automatic Mouth) text-to-speech for OpenClaw",
  "main": "scripts/sam-tts-wrapper.js",
  "scripts": {
    "test": "node scripts/sam-tts-wrapper.js 'Testing SAM TTS' --output=/tmp/sam-test.wav --quiet"
  },
  "dependencies": {
    "sam-js": "^0.3.0"
  },
  "keywords": ["tts", "sam", "voice", "openclaw", "skill"],
  "license": "MIT"
}


path: scripts/sam-tts-wrapper.js
content: #!/usr/bin/env node
/**
 * SAM TTS Wrapper - JSON output for OpenClaw integration
 * Adds proper WAV header for ffmpeg compatibility
 */

const fs = require('fs');
const path = require('path');

// Try to load sam-js
let SamJs;
try {
  SamJs = require('sam-js');
} catch (err) {
  console.error(JSON.stringify({
    success: false,
    error: 'sam-js not installed. Run: npm install'
  }));
  process.exit(1);
}

// Create WAV header for 8-bit mono PCM at 22050 Hz
function createWavHeader(dataLength) {
  const buffer = Buffer.alloc(44);
  const sampleRate = 22050;
  const numChannels = 1;
  const bitsPerSample = 8;
  const byteRate = sampleRate * numChannels * bitsPerSample / 8;
  const blockAlign = numChannels * bitsPerSample / 8;

  // RIFF chunk descriptor
  buffer.write('RIFF', 0);
  buffer.writeUInt32LE(36 + dataLength, 4);
  buffer.write('WAVE', 8);

  // fmt sub-chunk
  buffer.write('fmt ', 12);
  buffer.writeUInt32LE(16, 16); // Subchunk1Size (16 for PCM)
  buffer.writeUInt16LE(1, 20); // AudioFormat (1 for PCM)
  buffer.writeUInt16LE(numChannels, 22);
  buffer.writeUInt32LE(sampleRate, 24);
  buffer.writeUInt32LE(byteRate, 28);
  buffer.writeUInt16LE(blockAlign, 32);
  buffer.writeUInt16LE(bitsPerSample, 34);

  // data sub-chunk
  buffer.write('data', 36);
  buffer.writeUInt32LE(dataLength, 40);

  return buffer;
}

// Parse arguments
const args = process.argv.slice(2);
const options = {
  text: '',
  output: null,
  quiet: false,
  pitch: 64,
  speed: 72,
  mouth: 128,
  throat: 128,
  phonetic: false
};

for (let i = 0; i < args.length; i++) {
  const arg = args[i];
  
  if (arg === '--quiet') {
    options.quiet = true;
  } else if (arg === '--phonetic') {
    options.phonetic = true;
  } else if (arg.startsWith('--output=')) {
    options.output = arg.slice(9);
  } else if (arg.startsWith('--pitch=')) {
    options.pitch = parseInt(arg.slice(8), 10);
  } else if (arg.startsWith('--speed=')) {
    options.speed = parseInt(arg.slice(8), 10);
  } else if (arg.startsWith('--mouth=')) {
    options.mouth = parseInt(arg.slice(8), 10);
  } else if (arg.startsWith('--throat=')) {
    options.throat = parseInt(arg.slice(9), 10);
  } else if (!arg.startsWith('--') && !options.text) {
    options.text = arg;
  }
}

// Validate
if (!options.text) {
  console.error(JSON.stringify({
    success: false,
    error: 'No text provided. Usage: node sam-tts-wrapper.js "text" --output=/path/to/out.wav'
  }));
  process.exit(1);
}

if (!options.output) {
  console.error(JSON.stringify({
    success: false,
    error: 'No output path provided. Use --output=/path/to/out.wav'
  }));
  process.exit(1);
}

// Ensure output directory exists
const outputDir = path.dirname(options.output);
if (!fs.existsSync(outputDir)) {
  fs.mkdirSync(outputDir, { recursive: true });
}

// Generate audio
try {
  const sam = new SamJs({
    pitch: options.pitch,
    speed: options.speed,
    mouth: options.mouth,
    throat: options.throat,
    phonetic: options.phonetic
  });

  const pcmData = sam.buf8(options.text);
  const wavHeader = createWavHeader(pcmData.length);
  const wavBuffer = Buffer.concat([wavHeader, Buffer.from(pcmData)]);
  
  fs.writeFileSync(options.output, wavBuffer);

  const fileSize = fs.statSync(options.output).size;

  const result = {
    success: true,
    outputPath: options.output,
    size: fileSize,
    params: {
      pitch: options.pitch,
      speed: options.speed,
      mouth: options.mouth,
      throat: options.throat
    }
  };

  if (options.quiet) {
    console.log(JSON.stringify(result));
  } else {
    console.log('SAM TTS generated successfully:');
    console.log(JSON.stringify(result, null, 2));
  }

} catch (err) {
  const error = {
    success: false,
    error: err.message
  };
  console.error(options.quiet ? JSON.stringify(error) : JSON.stringify(error, null, 2));
  process.exit(1);
}


path: scripts/sam-tts.js
content: #!/usr/bin/env node
/**
 * SAM TTS CLI - Human-readable output
 * 
 * Usage: node sam-tts.js "text" output.wav [options]
 */

const fs = require('fs');

// Try to load sam-js
let SamJs;
try {
  SamJs = require('sam-js');
} catch (err) {
  console.error('Error: sam-js not installed.');
  console.error('Run: npm install');
  process.exit(1);
}

const args = process.argv.slice(2);

if (args.length < 2) {
  console.log('SAM TTS - Software Automatic Mouth');
  console.log('');
  console.log('Usage: node sam-tts.js "text" output.wav [options]');
  console.log('');
  console.log('Options:');
  console.log('  --pitch=N    Voice pitch (0-255, default: 64)');
  console.log('  --speed=N    Speech speed (1-255, default: 72, lower=faster)');
  console.log('  --mouth=N    Mouth cavity size (0-255, default: 128)');
  console.log('  --throat=N   Throat size (0-255, default: 128)');
  console.log('  --phonetic   Input is phonetic notation');
  console.log('');
  console.log('Examples:');
  console.log('  node sam-tts.js "Hello world" hello.wav');
  console.log('  node sam-tts.js "Hello world" hello.wav --pitch=80 --speed=60');
  process.exit(0);
}

const text = args[0];
const outputPath = args[1];

// Parse options
const options = {
  pitch: 64,
  speed: 72,
  mouth: 128,
  throat: 128,
  phonetic: false
};

for (let i = 2; i < args.length; i++) {
  const arg = args[i];
  if (arg.startsWith('--pitch=')) {
    options.pitch = parseInt(arg.slice(8), 10);
  } else if (arg.startsWith('--speed=')) {
    options.speed = parseInt(arg.slice(8), 10);
  } else if (arg.startsWith('--mouth=')) {
    options.mouth = parseInt(arg.slice(8), 10);
  } else if (arg.startsWith('--throat=')) {
    options.throat = parseInt(arg.slice(9), 10);
  } else if (arg === '--phonetic') {
    options.phonetic = true;
  }
}

// Generate
try {
  console.log(`Generating SAM audio...`);
  console.log(`Text: "${text}"`);
  console.log(`Output: ${outputPath}`);
  console.log(`Pitch: ${options.pitch}, Speed: ${options.speed}, Mouth: ${options.mouth}, Throat: ${options.throat}`);
  
  const sam = new SamJs({
    pitch: options.pitch,
    speed: options.speed,
    mouth: options.mouth,
    throat: options.throat,
    phonetic: options.phonetic
  });
  
  const wavBuffer = sam.buf8(text);
  fs.writeFileSync(outputPath, Buffer.from(wavBuffer));
  
  const stats = fs.statSync(outputPath);
  
  console.log(`✓ Generated: ${outputPath}`);
  console.log(`  Size: ${stats.size} bytes`);
  
} catch (err) {
  console.error(`Error: ${err.message}`);
  process.exit(1);
}


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