import { createMorsePlayer, updateAudioLock } from './audio.js';
import { getCallingStation } from './stationGenerator.js';
import { getInputs } from './inputs.js';
/**
* Compares the source and query strings based on specific fuzzy match criteria.
*
* @param {string} source - The source string to compare against.
* @param {string} query - The query string to compare with the source.
* @returns {string} - "perfect", "partial", or "none" based on the match.
*/
export function compareStrings(source, query) {
// Check for perfect match
if (source === query) {
// console.log("Perfect");
return 'perfect';
}
// Check Criterion 1 (Start of String Match)
if (criterion1(source, query)) {
// console.log("Partial: Criterion 1");
return 'partial';
}
// Check Criterion 2 (Middle or End of String Match)
if (criterion2(source, query)) {
// console.log("Partial: Criterion 2");
return 'partial';
}
// Check Criterion 3 (Off by One Character)
if (criterion3(source, query)) {
// console.log("Partial: Criterion 3");
return 'partial';
}
// Check Criterion 4 (Source is a Prefix of Query)
if (criterion4(source, query)) {
// console.log("Partial: Criterion 4");
return 'partial';
}
// Check Criterion 5 (Partial Match with Two Initial Characters Matching and One Off-by-One)
if (criterion5(source, query)) {
// console.log("Partial: Criterion 5");
return 'partial';
}
// If none of the criteria are met
// console.log("None");
return 'none';
/**
* Criterion 1: Start of String Match
*
* - **Conditions:**
* - Match of **1 character minimum**.
* - Query string **may not contain incorrect characters**.
* - Must match **at the start** of the source string.
*
* - **Examples:**
* - Source: "ABC", Query: "A" => partial
* - Source: "ABC", Query: "Z" => none
* - Source: "ABC", Query: "AX" => none
*
* @param {string} source
* @param {string} query
* @returns {boolean}
*/
function criterion1(source, query) {
// The query length must be at least 1 and not exceed the source length
if (query.length >= 1 && query.length <= source.length) {
// Check each character in the query against the source
for (let i = 0; i < query.length; i++) {
if (source[i] !== query[i]) {
return false; // Mismatch found
}
}
return true; // All characters match at the start
}
return false;
}
/**
* Criterion 2: Middle or End of String Match
*
* - **Conditions:**
* - Match of **2 consecutive characters minimum**.
* - Must match **in the middle or end** of the source string (not at the very start).
*
* - **Examples:**
* - Source: "ABC", Query: "BC" => partial
* - Source: "ABC", Query: "B" => none
*
* @param {string} source
* @param {string} query
* @returns {boolean}
*/
function criterion2(source, query) {
// The query length must be at least 2 and not exceed the source length
if (query.length >= 2 && query.length <= source.length) {
// Start from index 1 to avoid matching at the start of the source string
for (let i = 1; i <= source.length - query.length; i++) {
const substr = source.substring(i, i + query.length);
if (substr === query) {
return true; // Found a match in the middle or end
}
}
}
return false;
}
/**
* Criterion 3: Off by One Character
*
* - **Conditions:**
* - Match of **3 characters minimum** with the **4th character allowed to be off**.
* - At least **3 characters must match exactly**.
*
* - **Examples:**
* - Source: "ABCDE", Query: "BCZE" => partial
* - Source: "ABCDE", Query: "BCE" => none
* - Source: "ABCDE", Query: "ABXD" => partial
*
* @param {string} source
* @param {string} query
* @returns {boolean}
*/
function criterion3(source, query) {
// The query length must be at least 4 and not exceed the source length
if (query.length >= 4 && query.length <= source.length) {
// Iterate through the source string to find potential matches
for (let i = 0; i <= source.length - query.length; i++) {
const substr = source.substring(i, i + query.length);
let mismatches = 0;
// Compare each character in the query with the substring
for (let j = 0; j < query.length; j++) {
if (substr[j] !== query[j]) {
mismatches++;
if (mismatches > 1) {
break; // More than one mismatch, move to next substring
}
}
}
// Check if at least 3 characters match exactly
if (mismatches <= 1 && query.length - mismatches >= 3) {
return true; // Criteria met
}
}
}
return false;
}
/**
* Criterion 4: Source is a Prefix of Query
*
* - **Conditions:**
* - The **source string matches the beginning** of the query string exactly.
* - The match must **cover the entire source string**.
* - The **query string may have additional characters** at the end.
*
* - **Examples:**
* - Source: "ABC", Query: "ABCD" => partial
* - Source: "ABC", Query: "ABCDE" => partial
* - Source: "ABC", Query: "ABCX" => partial
*
* @param {string} source
* @param {string} query
* @returns {boolean}
*/
function criterion4(source, query) {
// The source must be non-empty and shorter than the query
if (source.length >= 1 && query.length > source.length) {
// Check if the source matches the start of the query
for (let i = 0; i < source.length; i++) {
if (source[i] !== query[i]) {
return false; // Mismatch found
}
}
return true; // Source is a prefix of query
}
return false;
}
/**
* Criterion 5: Partial Match with Two Initial Characters Matching and One Off-by-One
*
* - **Conditions:**
* - The query length must be at least 3.
* - The first two characters of the query must match the first two characters of the source exactly.
* - The third character in the query can differ from the source by one character.
* - Matches are checked specifically at the start of the source string.
*
* - **Examples:**
* - Source: "AB6ZZ", Query: "ABX"
* => 'A' matches 'A', 'B' matches 'B', and 'X' vs '6' is allowed as one mismatch.
* => returns true for partial.
*
* @param {string} source
* @param {string} query
* @returns {boolean}
*/
function criterion5(source, query) {
// The query must have at least 3 characters
if (query.length < 3) {
return false;
}
// Check if source has at least the length of the query
if (source.length < query.length) {
return false;
}
// Compare the first three characters:
// First two must match exactly
if (source[0] !== query[0] || source[1] !== query[1]) {
return false;
}
// The third character can differ by one character (off-by-one)
let mismatches = 0;
for (let i = 0; i < query.length; i++) {
if (source[i] !== query[i]) {
mismatches++;
if (mismatches > 1) {
return false;
}
}
}
return true;
}
}
// function runCompareStringTestCase(source, query, expectedResult) {
// const result = compareStrings(source, query);
// const passed = result === expectedResult;
// console.log(`Source: "${source}", Query: "${query}" => Expected: "${expectedResult}", Got: "${result}" - ${passed ? "PASSED" : "FAILED"}`);
// }
//
// const testCases = [
// // Perfect matches
// {source: "ABC", query: "ABC", expected: "perfect"},
// {source: "", query: "", expected: "perfect"},
// {source: "A", query: "A", expected: "perfect"},
//
// // Criterion 1 - Start of string match
// {source: "ABC", query: "A", expected: "partial"},
// {source: "ABC", query: "AB", expected: "partial"},
// {source: "ABC", query: "AX", expected: "none"},
// {source: "ABC", query: "ABX", expected: "none"},
// {source: "ABC", query: "Z", expected: "none"},
// {source: "ABC", query: "", expected: "none"},
// {source: "ABCDE", query: "ABC", expected: "partial"},
// {source: "ABCDE", query: "ABCD", expected: "partial"},
//
// // Criterion 2 - Middle or End of String
// {source: "ABC", query: "BC", expected: "partial"},
// {source: "ABCDE", query: "CD", expected: "partial"},
// {source: "ABCDE", query: "DE", expected: "partial"},
// {source: "ABCDE", query: "AB", expected: "none"},
// {source: "ABCDE", query: "B", expected: "none"},
// {source: "ABCDE", query: "E", expected: "none"},
// {source: "ABCDE", query: "ABCDE", expected: "perfect"},
// {source: "ABCDE", query: "XYZ", expected: "none"},
// {source: "ABCDE", query: "BCD", expected: "partial"},
// {source: "ABCDE", query: "BCDE", expected: "partial"},
//
// // Criterion 3 - Off by one character
// {source: "ABCDE", query: "BCZE", expected: "partial"},
// {source: "ABCDE", query: "BCE", expected: "none"},
// {source: "ABCDE", query: "ABXD", expected: "partial"},
// {source: "ABCDE", query: "ABXY", expected: "none"},
// {source: "ABCDE", query: "ABCDE", expected: "perfect"},
// {source: "ABCDE", query: "ABCXE", expected: "partial"},
// {source: "ABCDE", query: "ABCDF", expected: "partial"},
// {source: "ABCDE", query: "ABCD", expected: "partial"},
// {source: "ABCDE", query: "ABXDE", expected: "partial"},
// {source: "ABCDE", query: "ABXXE", expected: "none"},
//
// // Criterion 4 - Source is Prefix of Query
// {source: "ABC", query: "ABCD", expected: "partial"},
// {source: "ABC", query: "ABCDE", expected: "partial"},
// {source: "ABC", query: "ABCX", expected: "partial"},
// {source: "ABC", query: "ABCDX", expected: "partial"},
// {source: "AB", query: "ABCD", expected: "partial"},
// {source: "", query: "A", expected: "none"},
// {source: "ABC", query: "ABC", expected: "perfect"},
//
// // Edge cases
// {source: "ABCDE", query: "ABCDEFX", expected: "none"},
// {source: "ABCDE", query: "ABCDEF", expected: "partial"},
// {source: "ABCD", query: "ABCDE", expected: "partial"},
// {source: "ABCCDE", query: "ABXDE", expected: "none"},
// {source: "ABCD", query: "ABXY", expected: "none"},
// {source: "ABCDE", query: "ABC", expected: "partial"},
// {source: "ABCDE", query: "ABCD", expected: "partial"},
// {source: "", query: "", expected: "perfect"},
// {source: "", query: "AB", expected: "none"},
// {source: "A", query: "A", expected: "perfect"},
// {source: "A", query: "AB", expected: "partial"},
// {source: "A", query: "B", expected: "none"},
// ];
// for (const testCase of testCases) {
// runCompareStringTestCase(testCase.source, testCase.query, testCase.expected);
// }
/**
* Generates a weighted random number based on the number of stations.
* Lower-numbered stations have higher probabilities.
*
* @param {number} maxStations - The total number of stations.
* @returns {number} - A station number (1 to maxStations) based on weighted probability.
*/
export function weightedRandom(maxStations) {
// Step 1: Create weights inversely proportional to the station number
let weights = [];
for (let i = 1; i <= maxStations; i++) {
weights.push(1 / i); // Higher weight for lower numbers
}
// Step 2: Normalize weights so they sum to 1
let totalWeight = weights.reduce((a, b) => a + b, 0);
weights = weights.map((w) => w / totalWeight);
// Step 3: Generate a cumulative distribution from the weights
let cumulative = [];
weights.reduce((acc, w, i) => {
cumulative[i] = acc + w; // Accumulate the probabilities
return cumulative[i];
}, 0);
// Step 4: Generate a random number and find which station it corresponds to
let rand = Math.random(); // Random number between 0 and 1
for (let i = 0; i < cumulative.length; i++) {
if (rand < cumulative[i]) return i + 1; // Return 1-indexed station number
}
// Fallback in case no station is selected (shouldn't happen)
return maxStations;
}
// function testWeightedRandom() {
// const maxStations = 10; // Test with 10 stations
// const iterations = 10000; // Number of samples to collect
// const results = Array(maxStations).fill(0); // Array to store counts for each station
//
// // Collect results by running weightedRandom multiple times
// for (let i = 0; i < iterations; i++) {
// let station = weightedRandom(maxStations);
// results[station - 1]++; // Increment the count for the returned station
// }
//
// // Display results
// console.log(`Results after ${iterations} iterations with maxStations = ${maxStations}:`);
// results.forEach((count, index) => {
// console.log(`Station ${index + 1}: ${(count / iterations * 100).toFixed(2)}%`);
// });
// }
// Run the test
// testWeightedRandom();
/**
* Normalize the volume levels of a collection of station objects and create Morse players for each.
*
* This function takes in an array of station objects, each containing at least a `volume` property
* (a numeric value) and other station-specific properties (such as `callsign`). It calculates
* the total volume of all stations combined. If the total volume exceeds 1, the function normalizes
* all volumes so that the total does not surpass 1. This normalization ensures that multiple
* stations can play audio simultaneously without any single station dominating the output.
*
* After computing the normalization factor (scaling factor), it adjusts each station's volume by
* multiplying it by this factor. Then it creates a Morse player instance (`createMorsePlayer`) for
* each station at the new normalized volume. The resulting array of station objects, each with a
* `player` property containing the corresponding Morse player instance, is returned.
*
* @param {Array<Object>} stations - The array of station objects. Each station must have a `volume`
* property (number) and may include other properties such as `callsign`.
* @returns {Array<Object>} The array of station objects with their volumes normalized and a `player`
* instance created for each one.
*
* @example
* const stations = [
* { callsign: 'ABC', volume: 0.7 },
* { callsign: 'XYZ', volume: 0.6 }
* ];
*
* const normalized = normalizeStationGain(stations);
* // If total volume (1.3) is greater than 1, volumes are scaled down.
* // For example, ABC might now have a volume of 0.538 and XYZ might have 0.462
* // Each station in `normalized` now includes a `player` property.
*/
export function normalizeStationGain(stations) {
let normalizedStations = [];
// Normalize the volumes
let totalVolume = 0;
for (let i = 0; i < stations.length; i++) {
totalVolume += stations[i].volume;
}
// console.log(`Total volume: ${totalVolume}`);
// if totalVolume > 1, normalize
// Determine the scaling factor
let scalingFactor = 1;
if (totalVolume > 1) {
scalingFactor = 1 / totalVolume;
}
// console.log(`Scaling factor: ${scalingFactor}`);
for (let i = 0; i < stations.length; i++) {
let callingStation = stations[i];
let adjustedVolume = callingStation.volume * scalingFactor;
// console.log(`Adjusting volume for ${callingStation.callsign} from ${callingStation.volume} to ${adjustedVolume}`);
callingStation.player = createMorsePlayer(callingStation, adjustedVolume);
normalizedStations.push(callingStation);
}
return normalizedStations;
}
/**
* Responds by playing each station's Morse callsign after normalizing their volumes.
*
* Logs the callsigns, normalizes their volumes, and then uses each station's player
* to play their callsign. The `audioLock` parameter controls the start timing of playback.
*
* @param {Array<Object>} stations - Stations to respond to, each with a `callsign` and `volume`.
* @param {number} audioLock - Base time offset for playback start.
*/
export function respondWithAllStations(stations, audioLock) {
let inputs = getInputs();
// Ensure minWait is between 0 and 2, and maxWait is between 0 and 5
const minDelay = Math.max(0, Math.min(inputs.minWait, 2));
const maxDelay = Math.max(0, Math.min(inputs.maxWait, 5));
console.log(
'<-- Responding with stations: ' +
stations.map((station) => station.callsign)
);
stations = normalizeStationGain(stations);
for (let i = 0; i < stations.length; i++) {
const randomDelay = minDelay + Math.random() * maxDelay;
let responseTimer = stations[i].player.playSentence(
stations[i].callsign,
audioLock + randomDelay
);
updateAudioLock(responseTimer);
}
}
/**
* Adds new stations if the current count is below the maximum allowed.
*
* Uses a weighted random selection to determine how many new stations to add,
* logs details about each new station, updates the total active station count,
* and returns the updated array.
*
* @param {Array<Object>} stations - Current list of stations.
* @param {Object} inputs - Configuration object containing `maxStations`.
* @returns {Array<Object>} The updated list of stations.
*/
export function addStations(stations, inputs) {
// If currentStations is empty, then add a weighted random between 1 and inputs.maxStations
if (stations.length < inputs.maxStations) {
// Use weightedRandom to determine the number of stations to add
let numStations = weightedRandom(inputs.maxStations - stations.length);
console.log(`+ Adding ${numStations} stations...`);
for (let i = 0; i < numStations; i++) {
let callingStation = getCallingStation();
printStation(callingStation);
stations.push(callingStation);
}
}
updateActiveStations(stations.length);
return stations;
}
/**
* Prints out a station's information in a formatted manner.
*
* @param {Object} station - The station object to display.
*/
export function printStation(station) {
console.log('********************************');
console.log(`Station: ${station.callsign}`);
console.log('********************************');
for (const key of Object.keys(station)) {
console.log(` - ${key}: ${JSON.stringify(station[key], null, 2)},`);
}
console.log('================================');
}
/**
* Inserts a new row at the top of a specified HTML table body with provided data.
*
* @param {string} tableName - The ID of the HTML table element.
* @param {number} index - A numeric index or sequence number.
* @param {string} callsign - The callsign or identifier to display.
* @param {string} wpm - The words per minute speed (and Farnsworth spacing) to display.
* @param {number} attempts - The number of attempts to record.
* @param {number} totalTime - The total time taken, displayed to two decimal places.
* @param {string|null} [extra=null] - Optional additional information to include in a fifth cell.
*/
export function addTableRow(
tableName,
index,
callsign,
wpm,
attempts,
totalTime,
extra = null
) {
const table = document
.getElementById(tableName)
.getElementsByTagName('tbody')[0];
// Create a new row at the top
const newRow = table.insertRow(0);
// Add cells and populate them
newRow.insertCell(0).textContent = index;
newRow.insertCell(1).textContent = callsign;
newRow.insertCell(2).textContent = wpm;
newRow.insertCell(3).textContent = attempts;
newRow.insertCell(4).textContent = totalTime.toFixed(2);
if (extra) {
newRow.insertCell(5).innerHTML = extra;
}
// Update the summary row at the bottom
updateSummaryRow(tableName, extra);
}
/**
* Removes all rows from the specified table body.
*
* @param {string} tableName - The ID of the HTML table element.
*/
export function clearTable(tableName) {
const tableBody = document
.getElementById(tableName)
.getElementsByTagName('tbody')[0];
// Clear all rows in the table body
while (tableBody.firstChild) {
tableBody.removeChild(tableBody.firstChild);
}
}
/**
* Computes totals and averages for the current rows (excluding the very first row),
* and inserts/updates a summary row at the bottom. If there are fewer than 2 data rows,
* no summary row is shown.
*
* @param {string} tableName - The ID of the HTML table element.
* @param {string|null} [extra=null] - Optional additional information to include in a fifth cell.
*/
function updateSummaryRow(tableName, extra = null) {
const table = document.getElementById(tableName);
const tableBody = table.getElementsByTagName('tbody')[0];
// Remove any existing summary row
const existingSummary = document.getElementById(tableName + '-summary');
if (existingSummary) {
existingSummary.remove();
}
// Gather row data
let rows = Array.from(tableBody.rows);
// If fewer than 2 rows, no summary row is meaningful
// (we can't compute averages if we skip the first row and end up with nothing)
if (rows.length < 2) {
return;
}
const wpmValues = []; // Will store WPM strings
const attemptsList = []; // Will store numeric attempts
const timeList = []; // Will store total times
for (const row of rows) {
// WPM in cell[2], Attempts in cell[3], Time in cell[4]
const wpmVal = row.cells[2].textContent;
const attemptsVal = parseInt(row.cells[3].textContent, 10);
const timeVal = parseFloat(row.cells[4].textContent);
wpmValues.push(wpmVal);
attemptsList.push(attemptsVal);
timeList.push(timeVal);
}
const rowCount = rows.length;
// --- Compute average attempts
const sumAttempts = attemptsList.reduce((a, b) => a + b, 0);
const avgAttempts = sumAttempts / rowCount;
// --- Compute average total time
const sumTime = timeList.reduce((a, b) => a + b, 0);
const avgTime = sumTime / rowCount;
// --- Compute average WPM (check if we have slashes)
const hasSlash = wpmValues.some((val) => val.includes('/'));
let finalWpm;
if (hasSlash) {
// Treat all as slash. If a row does not have '/', treat like "xx/xx"
let sumWpm1 = 0;
let sumWpm2 = 0;
for (const val of wpmValues) {
if (val.includes('/')) {
const [part1, part2] = val.split('/');
sumWpm1 += parseInt(part1, 10);
sumWpm2 += parseInt(part2, 10);
} else {
const num = parseInt(val, 10);
sumWpm1 += num;
sumWpm2 += num;
}
}
const avgWpm1 = sumWpm1 / rowCount;
const avgWpm2 = sumWpm2 / rowCount;
// Round or format as desired; here we use integers
finalWpm = avgWpm1.toFixed(1) + ' / ' + avgWpm2.toFixed(1);
} else {
// All are single WPM
const sumWpm = wpmValues.reduce((sum, val) => sum + parseInt(val, 10), 0);
const avgWpm = sumWpm / rowCount;
finalWpm = avgWpm.toFixed(1).toString();
}
// --- Insert the new summary row at the bottom
const summaryRow = tableBody.insertRow(-1);
summaryRow.id = tableName + '-summary';
// Make everything in the summary row bold; add " total" to avgTime
summaryRow.insertCell(0).innerHTML = ``;
summaryRow.insertCell(1).innerHTML = `<strong>Avg</strong>`;
summaryRow.insertCell(2).innerHTML = `<strong>${finalWpm}</strong>`;
summaryRow.insertCell(3).innerHTML =
`<strong>${avgAttempts.toFixed(1)}</strong>`;
summaryRow.insertCell(4).innerHTML = `<strong>${avgTime.toFixed(2)}</strong>`;
summaryRow.insertCell(5).innerHTML = ``;
}
/**
* Updates the displayed number of active stations.
*
* @param {number} numStations - The current count of active stations.
*/
export function updateActiveStations(numStations) {
document.getElementById('activeStations').textContent = numStations;
}