Source: util/sound.js

/**
 * @file audioProcessing.js
 * @description Handles audio processing tasks such as recording, playing sounds, and transcribing audio files.
 * @author Lior Jigalo
 * @license MIT
 */
const { exec, execFile } = require('child_process');
const path = require("path");
const { spawn } = require('child_process');

const ffmpeg = require('fluent-ffmpeg');
const {confirmationPhrases, noResponsePhrases} = require("./const");
const {user_status, locks} = require("./global");
const {printToConsole} = require("./util");
const stemmer = natural.PorterStemmer; // Using Porter Stemmer

const natural = require('natural');
const tokenizer = new natural.WordTokenizer();

/**
 * Mutes all audio streams using a linux command.
 */
function muteAllStreams() {
  exec("pactl list sink-inputs | grep -oP '(?<=Sink Input #)\\d+'", (err, stdout) => {
    if (err) {
      console.error(`Error listing streams: ${err}`);
      return;
    }
    const streamIds = stdout.trim().split('\n');
    streamIds.forEach(streamId => {
      exec(`pactl set-sink-input-mute ${streamId} 1`);
    });
  });
}

/**
 * Unmutes all audio streams using a linux command.
 */
function unmuteAllStreams() {
    exec("pactl list sink-inputs | grep -oP '(?<=Sink Input #)\\d+'", (err, stdout) => {
        if (err) {
            console.error(`Error listing streams: ${err}`);
            return;
        }
        const streamIds = stdout.trim().split('\n');
        streamIds.forEach(streamId => {
            exec(`pactl set-sink-input-mute ${streamId} 0`);
        });
    });
}

/**
 * Plays a sound file.
 * @param {string} filePath - Path to the WAV file to play.
 * @returns {Promise<void>} - Resolves when the sound has finished playing.
 */
function playSound(filePath) {
    return new Promise((resolve, reject) => {
        // muteAllStreams();
        const player = spawn('aplay', [filePath]);
        // const player = spawn('aplay', ['-D', 'plughw:1,0', filePath]);

        player.on('error', (err) => {
            console.error(`Error playing sound: ${err.message}`);
            reject(err);
        });

        player.on('close', (code) => {
            if (code !== 0) {
                reject(new Error(`Sound player exited with code ${code}`));
                locks.alert_lock = false;
            } else {
                resolve();
            }
        });
        // setTimeout(unmuteAllStreams, 4000);
    });
}

/** Preprocesses confirmation phrases with stemming. */
const stemmedConfirmationPhrases = confirmationPhrases.map(phrase => {
    return phrase.split(' ').map(word => stemmer.stem(word)).join(' ');
});

/** Preprocesses no-response phrases with stemming. */
const stemmedNoResponsePhrases = noResponsePhrases.map(phrase => {
    return phrase.split(' ').map(word => stemmer.stem(word)).join(' ');
});

/**
 * Checks if the transcription includes a confirmation of alertness.
 * @param {Array<string>} lines - Parsed transcription lines.
 * @returns {number} - User status indicating the response type.
 */
function hasAlertConfirmation(lines) {
    for (const line of lines) {
        // Tokenize the line into words
        const words = tokenizer.tokenize(line.toLowerCase());

        // Stem each word
        const stemmedWords = words.map(word => stemmer.stem(word));

        // Reconstruct the line from stemmed words
        const stemmedLine = stemmedWords.join(' ');

        // Check against each stemmed no response phrase
        for (const phrase of stemmedNoResponsePhrases) {
            if (stemmedLine.includes(phrase))
                return user_status.noResponse;
        }

        // Check against each stemmed confirmation phrase
        for (const phrase of stemmedConfirmationPhrases) {
            if (stemmedLine.includes(phrase))
                return user_status.userResponded;
        }
    }

    if (lines.length === 0)
        return user_status.noResponse;
    return user_status.failedToParse;
}

/**
 * Transcribes a WAV file using whisper-cli.
 * @param {string} audioFilePath - The path to the .wav file.
 * @returns {Promise<string>} - The raw transcription output from whisper-cli.
 */
async function transcribeWithWhisper(audioFilePath) {
  // Path to the whisper-cli binary
  const whisperCliPath = path.join(__dirname, "../assets/bin", "whisper-cli");
  // Path to the whisper model
  const modelPath = path.join(__dirname, "../assets/models", "ggml-tiny.en.bin");

  // Build arguments
  const args = ["--model", modelPath, "--no-prints", audioFilePath];

  return new Promise((resolve, reject) => {
    // We do NOT pass a callback here, because we want manual streaming.
    const child = execFile(whisperCliPath, args, { shell: false });

    let stdoutData = "";
    let stderrData = "";

    child.stdout.on("data", (data) => {
      stdoutData += data.toString();
    });

    child.stderr.on("data", (data) => {
      stderrData += data.toString();
    });

    child.on("close", (code) => {
      if (code !== 0) {
        return reject(
          new Error(
            `whisper-cli failed with exit code ${code}.\nStderr: ${stderrData}`
          )
        );
      }
      // Return the entire raw transcription output
      resolve(stdoutData.trim());
    });

    // If an error is emitted at the process level, catch it
    child.on("error", (err) => {
      reject(err);
    });
  });
}


/**
 * Parses the raw transcription lines from whisper-cli output.
 * @param {string} rawOutput - Raw output from whisper-cli.
 * @returns {Array<string>} - Parsed transcription lines.
 */
function parseWhisperOutput(rawOutput) {

  // Remove bracketed part using a regex and keep text portion
  const lines = rawOutput.split("\n").filter(Boolean);

  return lines.map((line) =>
    line.replace(/^\[[^\]]*\]\s*/, "").trim()
  );
}

/**
 * Records audio using ffmpeg and saves it as a WAV file in 16kHz mono format.
 * @param {string} outputFilePath - Path to save the recorded WAV file.
 * @param {number} durationInSeconds - Duration to record in seconds.
 * @returns {Promise<void>}
 */
function recordAudioWithFFmpeg(outputFilePath, durationInSeconds = 5) {
    return new Promise((resolve, reject) => {

        ffmpeg()
            .input('default')
            .inputFormat('alsa')
            .audioFrequency(16000)
            .audioChannels(1)
            .audioCodec('pcm_s16le')
            .format('wav')
            .duration(durationInSeconds)
            .on('end', () => {
                resolve();
            })
            .on('error', (err) => {
                reject(err);
            })
            .save(outputFilePath);
    });
}

/**
 * Prompts the user for a confirmation of alertness by recording and analyzing audio.
 * @returns {Promise<number>} - User status based on their response.
 */
async function askForUserConfirmation() {
    const userAudioPath = path.join(__dirname, "../assets/sounds", "userCollected.wav");
    // Record audio using ffmpeg
    await recordAudioWithFFmpeg(userAudioPath, 4);
    // Get output from whisper-cli
    const rawOutput = await transcribeWithWhisper(userAudioPath);
    // Parse timestamps
    const linesWithoutTimestamps = parseWhisperOutput(rawOutput);
    printToConsole(linesWithoutTimestamps)

    // check user response
    return  hasAlertConfirmation(linesWithoutTimestamps);
}

/** Exported functions */
module.exports = {
    playSound,
    askForUserConfirmation
};