Source: audio.js

import { getInputs } from './inputs.js';

/**
 * Creates a Morse code audio player for a specified station.
 *
 * Configures an oscillator and gain node to produce audio signals representing
 * Morse code. Supports Farnsworth timing adjustments and simulates QSB (fading)
 * effects if enabled. Includes mappings for letters, numbers, punctuation, and
 * prosigns. Exposes a `playSentence` method for playing Morse sequences.
 *
 * @param {Object} station - The station configuration with attributes like volume, frequency, and WPM.
 * @param {number|null} volumeOverride - Optional override for the station's volume.
 * @returns {Object|null} An object with methods to play Morse sequences or null if inputs are invalid.
 */
export function createMorsePlayer(station, volumeOverride = null) {
  let volume = volumeOverride !== null ? volumeOverride : station.volume;

  const inputs = getInputs();
  if (inputs === null) return;

  const enableFarnsworth = station.enableFarnsworth;
  const farnsworthSpeed = station.farnsworthSpeed || station.wpm; // fallback if not set

  console.log(
    `/ Initializing ${station.callsign}: ${station.frequency}Hz@${station.wpm}wpm` +
      `${enableFarnsworth ? `/${station.farnsworthSpeed}wpm` : ''}` +
      ` vol: ${volume.toFixed(2)}` +
      `${station.qsb ? ` (QSB:${station.qsbDepth.toFixed(2)}A@${station.qsbFrequency.toFixed(2)}Hz)` : ''}`
  );

  let context = audioContext;

  // QSB parameters
  const qsb = station.qsb === true;
  const qsbDepth = qsb ? station.qsbDepth : 0;
  const qsbFrequency = qsb ? station.qsbFrequency : 0;
  const stationStartTime = context.currentTime;
  // Introduce a random phase offset for QSB so multiple stations differ
  const qsbPhaseOffset = qsb ? Math.random() * 2 * Math.PI : 0;

  // Calculate timing constants in seconds
  // Character speed unit (from station's wpm)
  const CHAR_UNIT = 1.2 / station.wpm;
  // Farnsworth speed unit (if enabled, otherwise just station speed)
  const FARNS_UNIT = 1.2 / farnsworthSpeed;

  // Dot/Dash and symbol spacing use character speed
  const DOT_TIME = CHAR_UNIT;
  const DASH_TIME = CHAR_UNIT * 3;
  const SYMBOL_SPACE = CHAR_UNIT;

  // Letter and word spacing change if Farnsworth is enabled
  const LETTER_SPACE = enableFarnsworth ? FARNS_UNIT * 3 : CHAR_UNIT * 3;
  const WORD_SPACE = enableFarnsworth ? FARNS_UNIT * 7 : CHAR_UNIT * 7;

  const oscillator = context.createOscillator();
  const gainNode = context.createGain();

  oscillator.type = 'sine';
  oscillator.frequency.value = station.frequency;
  gainNode.gain.value = 0; // Start with no volume
  oscillator.connect(gainNode);
  gainNode.connect(context.destination);
  oscillator.start();

  // Morse code mapping including prosigns
  const morseCodeMap = {
    // Letters
    a: '.-',
    b: '-...',
    c: '-.-.',
    d: '-..',
    e: '.',
    f: '..-.',
    g: '--.',
    h: '....',
    i: '..',
    j: '.---',
    k: '-.-',
    l: '.-..',
    m: '--',
    n: '-.',
    o: '---',
    p: '.--.',
    q: '--.-',
    r: '.-.',
    s: '...',
    t: '-',
    u: '..-',
    v: '...-',
    w: '.--',
    x: '-..-',
    y: '-.--',
    z: '--..',
    // Numbers
    0: '-----',
    1: '.----',
    2: '..---',
    3: '...--',
    4: '....-',
    5: '.....',
    6: '-....',
    7: '--...',
    8: '---..',
    9: '----.',
    // Punctuation
    '.': '.-.-.-',
    ',': '--..--',
    '?': '..--..',
    '/': '-..-.',
    // Prosigns
    '<bk>': '-...-.-',
    '<ar>': '.-.-.',
    '<sk>': '...-.-',
    '<kn>': '-.--.',
    '<bt>': '-...-',
  };

  /**
   * Tokenizes a string into individual Morse code symbols and prosigns.
   *
   * Identifies prosigns enclosed in angle brackets (e.g., `<ar>`) and treats
   * them as distinct tokens. Splits the string into recognizable Morse components.
   *
   * @param {string} text - The text to tokenize.
   * @returns {string[]} An array of tokens representing Morse code symbols and prosigns.
   */
  function tokenize(text) {
    const tokens = [];
    let i = 0;
    while (i < text.length) {
      if (text[i] === '<') {
        // Start of a prosign
        const endIndex = text.indexOf('>', i);
        if (endIndex !== -1) {
          tokens.push(text.substring(i, endIndex + 1));
          i = endIndex + 1;
        } else {
          // No closing '>', treat '<' as a normal character
          tokens.push(text[i]);
          i++;
        }
      } else if (text[i] === ' ') {
        tokens.push(' ');
        i++;
      } else {
        tokens.push(text[i]);
        i++;
      }
    }
    return tokens;
  }

  /**
   * Calculates the amplitude of a signal at a given time with optional QSB (fading) effects.
   *
   * Models QSB (fading) by oscillating the volume between full (no attenuation) and a reduced level
   * (attenuated by `qsbDepth`). The amplitude is calculated using a sine wave function:
   *
   *     amplitude(t) = volume * [1 - qsbDepth * ((sin(2π * qsbFrequency * (t - stationStartTime) + qsbPhaseOffset) + 1) / 2)]
   *
   * - `volume` is the base amplitude of the signal.
   * - `qsbDepth` determines the range of attenuation, where 0 means no fading and 1 means full fade.
   * - `qsbFrequency` defines the speed of the fading in Hz.
   * - `stationStartTime` anchors the time, ensuring consistent phase behavior for multiple stations.
   * - `qsbPhaseOffset` introduces a random phase offset to differentiate the behavior of overlapping signals.
   * - The sine function oscillates between -1 and 1, which is normalized to a range of 0 to 1.
   *
   * This function returns the adjusted amplitude based on the QSB parameters at the given time.
   * If QSB is disabled, it simply returns the base volume.
   *
   * @param {number} t - The current time in seconds.
   * @returns {number} The adjusted amplitude considering QSB effects.
   */
  function qsbAmplitude(t) {
    if (!qsb) return volume;
    const sineValue = Math.sin(
      2 * Math.PI * (t - stationStartTime) + qsbPhaseOffset
    );
    const fadeFactor = (sineValue + 1) / 2; // Maps sin(-1..1) to 0..1
    const qsbFactor = 1 - qsbDepth * fadeFactor;
    return volume * qsbFactor;
  }

  /**
   * Schedules playback of a single Morse code symbol (dot or dash) with smooth volume ramps for pleasing tones.
   *
   * This function schedules the gain node to create a smooth attack and release for the symbol's volume,
   * ensuring a natural sound. The process involves:
   *
   * - **Attack**: Gradually increases the volume from a minimum value (`minGain`) to the desired volume
   *   over a small fraction of the symbol's duration (`attackFraction`). This eliminates harsh clicks
   *   at the start of the tone.
   * - **Sustain**: Holds the volume steady for the majority of the symbol's duration.
   * - **Release**: Gradually decreases the volume back to a minimum over another fraction of the symbol's
   *   duration (`releaseFraction`), avoiding abrupt stops.
   *
   * The attack and release times are calculated as:
   *
   *     attackTime = min(duration * attackFraction, maxAttackReleaseTime)
   *     releaseTime = min(duration * releaseFraction, maxAttackReleaseTime)
   *
   * The total duration of the symbol is determined by its type:
   * - A dot (`.`) has a duration of `DOT_TIME`.
   * - A dash (`-`) has a duration of `DASH_TIME`.
   *
   * Additionally, the function samples the QSB amplitude at the middle of the attack phase (`time + attackTime`)
   * to set the volume dynamically based on the station's QSB configuration.
   *
   * Key steps in the playback:
   * 1. Volume ramps up from `minGain` to the target amplitude during the attack phase.
   * 2. Volume holds steady at the target amplitude during the sustain phase.
   * 3. Volume ramps down from the target amplitude back to `minGain` during the release phase.
   * 4. Ensures the gain returns to zero (`0`) shortly after the release for silence between symbols.
   *
   * @param {string} symbol - The Morse code symbol to play (`.` for dot or `-` for dash).
   * @param {number} time - The start time for the symbol in seconds.
   * @returns {number} The updated time after the symbol is played, including its duration.
   */
  function playSymbol(symbol, time) {
    const duration = symbol === '-' ? DASH_TIME : DOT_TIME;
    const minGain = 0.001; // Minimum gain to avoid zero in exponential ramp

    // Calculate attack and release times as a fraction of the symbol duration
    const attackFraction = 0.1;
    const releaseFraction = 0.1;
    const maxAttackReleaseTime = 0.01; // 10 ms max

    const attackTime = Math.min(
      duration * attackFraction,
      maxAttackReleaseTime
    );
    const releaseTime = Math.min(
      duration * releaseFraction,
      maxAttackReleaseTime
    );

    // Determine the amplitude at the start of the symbol considering QSB
    // We'll sample the QSB amplitude at time + attackTime for a stable ramp target
    const symbolMidTime = time + attackTime;
    const symbolVolume = qsbAmplitude(symbolMidTime);

    // Schedule gain to ramp up smoothly (attack)
    gainNode.gain.setValueAtTime(minGain, time);
    gainNode.gain.exponentialRampToValueAtTime(symbolVolume, symbolMidTime);

    // Maintain the gain at the desired volume
    gainNode.gain.setValueAtTime(symbolVolume, symbolMidTime);
    gainNode.gain.setValueAtTime(symbolVolume, time + duration - releaseTime);

    // Schedule gain to ramp down smoothly (release)
    gainNode.gain.exponentialRampToValueAtTime(minGain, time + duration);

    // Ensure gain goes back to zero after release
    gainNode.gain.setValueAtTime(0, time + duration + 0.001);

    return time + duration;
  }

  /**
   * Plays a sequence of Morse code symbols.
   *
   * Iterates through each symbol in the code, playing dots and dashes with proper
   * spacing. Applies intra-character and inter-character timing adjustments.
   *
   * @param {string} code - The Morse code sequence to play.
   * @param {number} time - The start time for the sequence in seconds.
   * @returns {number} The updated time after the code is played.
   */
  function playCode(code, time) {
    for (let i = 0; i < code.length; i++) {
      const symbol = code[i];
      if (symbol === '.' || symbol === '-') {
        time = playSymbol(symbol, time);
        // Always add intra-character space after each symbol
        time += SYMBOL_SPACE;
      }
    }
    time += LETTER_SPACE - SYMBOL_SPACE;
    return time;
  }

  /**
   * Plays a single token, either a Morse code sequence or a word space.
   *
   * Recognizes spaces as word boundaries and adjusts timing accordingly. Looks up
   * tokens in the Morse code map and plays them using `playCode`.
   *
   * @param {string} token - The token to play (character, prosign, or space).
   * @param {number} time - The start time for the token in seconds.
   * @returns {number} The updated time after the token is played.
   */
  function playToken(token, time) {
    if (token === ' ') {
      // Adjust time to include word space (subtract last LETTER_SPACE added)
      time += WORD_SPACE - LETTER_SPACE;
    } else {
      const code = morseCodeMap[token.toLowerCase()];
      if (code) {
        time = playCode(code, time);
      } else {
        console.warn(`Unrecognized token: ${token}`);
      }
    }
    return time;
  }

  /**
   * Plays a full sentence as Morse code.
   *
   * Tokenizes the sentence and plays each token sequentially. Applies word spacing
   * and adjusts timing based on Farnsworth settings if enabled.
   *
   * @param {string} sentence - The sentence to play.
   * @param {number} startTime - The starting time for playback, defaults to current time.
   * @returns {number} The final time after the sentence is played.
   */
  function playSentence(sentence, startTime = context.currentTime) {
    // Uncomment the following line to log the sentence being played (for debugging)
    // console.log(`/ Playing sentence: ${sentence}`);

    let time = startTime;
    const tokens = tokenize(sentence);
    for (let i = 0; i < tokens.length; i++) {
      const token = tokens[i];
      time = playToken(token, time);
    }
    return time;
  }

  return { playSentence, context };
}

// Audio lock
export let audioContext = new AudioContext();
export let audioLockUntil = 0;

/**
 * Updates the audio lock time.
 *
 * Prevents overlapping playback by ensuring no new audio can play
 * until after the specified lock time.
 *
 * @param {number} time - The new lock time in seconds.
 */
export function updateAudioLock(time) {
  if (time > audioLockUntil) {
    audioLockUntil = time;
  }
}

/**
 * Checks whether the audio lock is currently active.
 *
 * Compares the current audio context time with the lock time to determine
 * if new audio playback is allowed.
 *
 * @returns {boolean} True if the audio lock is active, false otherwise.
 */
export function getAudioLock() {
  return audioContext.currentTime < audioLockUntil;
}

let backgroundStaticSource = null;
let backgroundStaticContext = new AudioContext();
let staticGain = null;

/**
 * Creates a background static noise track for QRN simulation.
 *
 * Configures a looping audio source based on the selected QRN level (e.g., normal, moderate, heavy).
 * Adjusts gain to match the QRN intensity and connects the source to the audio context.
 *
 * Ensures only one static track is active at a time.
 */
export function createBackgroundStatic() {
  if (backgroundStaticSource) return; // Ensure only one static track is playing

  const inputs = getInputs();
  if (inputs === null) return; // Do not create static if inputs are invalid
  const selectedQRN = inputs.qrn;

  if (selectedQRN === 'off') {
    return; // Do not create static if "off" is selected
  }

  let staticGainValues = {
    normal: 0.75,
    moderate: 1.5,
    heavy: 3.0,
  };

  console.log(`/ Initializing background static for QRN level ${selectedQRN}`);

  const context = backgroundStaticContext;
  const staticUrl = '../audio/static.mp3';

  // Fetch and decode the audio file
  fetch(staticUrl)
    .then((response) => response.arrayBuffer())
    .then((arrayBuffer) => context.decodeAudioData(arrayBuffer))
    .then((audioBuffer) => {
      backgroundStaticSource = context.createBufferSource();
      backgroundStaticSource.buffer = audioBuffer;
      backgroundStaticSource.loop = true;

      staticGain = context.createGain();
      staticGain.gain.value = staticGainValues[selectedQRN] || 1.0;

      backgroundStaticSource.connect(staticGain);
      staticGain.connect(context.destination);

      backgroundStaticSource.start();
    })
    .catch((error) => {
      console.error('Error loading static audio file:', error);
    });
}

/**
 * Stops the background static noise track.
 *
 * Optionally applies a fade-out effect before stopping the audio source. Disconnects
 * all related audio nodes and cleans up resources after stopping.
 *
 * @param {boolean} noFade - If true, stops the static immediately without fading.
 */
export function stopBackgroundStatic(noFade = false) {
  if (backgroundStaticSource) {
    console.log('Stopping background static');

    if (staticGain) {
      const fadeTime = noFade ? 0 : 1; // Fade out over 1 second
      const currentTime = backgroundStaticContext.currentTime;
      staticGain.gain.setValueAtTime(staticGain.gain.value, currentTime);
      staticGain.gain.linearRampToValueAtTime(0, currentTime + fadeTime);
      updateAudioLock(audioContext.currentTime + fadeTime);
    }

    if (noFade) {
      // Stop and clean up immediately
      backgroundStaticSource.stop();
      backgroundStaticSource.disconnect();
      staticGain.disconnect();
      backgroundStaticSource = null;
      staticGain = null;
    } else {
      // Stop after fade-out
      setTimeout(() => {
        if (backgroundStaticSource) {
          backgroundStaticSource.stop();
          backgroundStaticSource.disconnect();
        }
        if (staticGain) {
          staticGain.disconnect();
        }
        backgroundStaticSource = null;
        staticGain = null;
      }, 1000);
    }
  }
}

/**
 * Checks whether the background static noise is currently playing.
 *
 * @returns {boolean} True if the static noise source is active, false otherwise.
 */
export function isBackgroundStaticPlaying() {
  return backgroundStaticSource !== null;
}

/**
 * Updates the intensity of the background static noise.
 *
 * Stops any currently playing static noise and attempts to recreate it
 * with the updated QRN settings.
 */
export function updateStaticIntensity() {
  if (isBackgroundStaticPlaying()) {
    // Always stop any existing background static
    stopBackgroundStatic(true);
    // Attempt to create new background static
    createBackgroundStatic();
  }
}

/**
 * Stops all audio playback and resets the audio context.
 *
 * Closes the current audio context, clears the audio lock, and stops
 * any active background static noise. Reinitializes a new audio context.
 */
export function stopAllAudio() {
  audioContext.close();
  audioContext = new AudioContext();
  audioLockUntil = 0;

  stopBackgroundStatic();
}