Refactor: Remove audio processing and game state management modules

This commit is contained in:
2025-10-15 23:33:40 +02:00
parent 56d7511bd6
commit 58c668de63
69 changed files with 5836 additions and 1319 deletions

View File

@@ -0,0 +1,317 @@
/**
* Answer checking service for fuzzy matching of title, artist, and year guesses
* Based on the original answerCheck.js logic
*/
export interface ScoreResult {
score: number;
match: boolean;
}
/**
* Answer checking service
*/
export class AnswerCheckService {
/**
* Strip diacritics from string
*/
private stripDiacritics(str: string): string {
return str
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '');
}
/**
* Normalize common terms
*/
private normalizeCommon(str: string): string {
let normalized = this.stripDiacritics(str)
.toLowerCase()
// Normalize common contractions before removing punctuation
.replace(/\bcan't\b/g, 'cant')
.replace(/\bwon't\b/g, 'wont')
.replace(/\bdon't\b/g, 'dont')
.replace(/\bdidn't\b/g, 'didnt')
.replace(/\bisn't\b/g, 'isnt')
.replace(/\baren't\b/g, 'arent')
.replace(/\bwasn't\b/g, 'wasnt')
.replace(/\bweren't\b/g, 'werent')
.replace(/\bhasn't\b/g, 'hasnt')
.replace(/\bhaven't\b/g, 'havent')
.replace(/\bhadn't\b/g, 'hadnt')
.replace(/\bshouldn't\b/g, 'shouldnt')
.replace(/\bwouldn't\b/g, 'wouldnt')
.replace(/\bcouldn't\b/g, 'couldnt')
.replace(/\bmustn't\b/g, 'mustnt')
.replace(/\bi'm\b/g, 'im')
.replace(/\byou're\b/g, 'youre')
.replace(/\bhe's\b/g, 'hes')
.replace(/\bshe's\b/g, 'shes')
.replace(/\bit's\b/g, 'its')
.replace(/\bwe're\b/g, 'were')
.replace(/\bthey're\b/g, 'theyre')
.replace(/\bi've\b/g, 'ive')
.replace(/\byou've\b/g, 'youve')
.replace(/\bwe've\b/g, 'weve')
.replace(/\bthey've\b/g, 'theyve')
.replace(/\bi'll\b/g, 'ill')
.replace(/\byou'll\b/g, 'youll')
.replace(/\bhe'll\b/g, 'hell')
.replace(/\bshe'll\b/g, 'shell')
.replace(/\bwe'll\b/g, 'well')
.replace(/\bthey'll\b/g, 'theyll')
.replace(/\bthat's\b/g, 'thats')
.replace(/\bwho's\b/g, 'whos')
.replace(/\bwhat's\b/g, 'whats')
.replace(/\bwhere's\b/g, 'wheres')
.replace(/\bwhen's\b/g, 'whens')
.replace(/\bwhy's\b/g, 'whys')
.replace(/\bhow's\b/g, 'hows')
.replace(/\s*(?:&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/g, ' ')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized;
}
/**
* Clean title-specific noise (remasters, edits, etc.)
*/
private cleanTitleNoise(raw: string): string {
let s = raw;
// Remove common parenthetical annotations
s = s.replace(/\(([^)]*remaster[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*radio edit[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*edit[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*version[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*live[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
// Remove standalone noise words
s = s.replace(/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi, '');
return s;
}
/**
* Strip optional segments (parentheses, quotes, brackets)
*/
private stripOptionalSegments(raw: string): string {
let s = raw;
s = s.replace(/"[^"]*"/g, ' '); // Remove quoted segments
s = s.replace(/\([^)]*\)/g, ' '); // Remove parenthetical
s = s.replace(/\[[^\]]*\]/g, ' '); // Remove brackets
return s;
}
/**
* Normalize title for comparison
*/
private normalizeTitle(str: string): string {
return this.normalizeCommon(this.cleanTitleNoise(str));
}
/**
* Normalize title with optional segments removed
*/
private normalizeTitleBaseOptional(str: string): string {
return this.normalizeCommon(this.stripOptionalSegments(this.cleanTitleNoise(str)));
}
/**
* Normalize artist for comparison
*/
private normalizeArtist(str: string): string {
return this.normalizeCommon(str)
.replace(/\bthe\b/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Tokenize string
*/
private tokenize(str: string): string[] {
return str ? str.split(' ').filter(Boolean) : [];
}
/**
* Create token set
*/
private tokenSet(str: string): Set<string> {
return new Set(this.tokenize(str));
}
/**
* Calculate Jaccard similarity
*/
private jaccard(a: string, b: string): number {
const setA = this.tokenSet(a);
const setB = this.tokenSet(b);
if (setA.size === 0 && setB.size === 0) return 1;
let intersection = 0;
for (const token of setA) {
if (setB.has(token)) intersection++;
}
const union = setA.size + setB.size - intersection;
return union ? intersection / union : 0;
}
/**
* Calculate Levenshtein distance
*/
private levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
if (!m) return n;
if (!n) return m;
const dp = new Array(n + 1).fill(0);
for (let j = 0; j <= n; j++) {
dp[j] = j;
}
for (let i = 1; i <= m; i++) {
let prev = dp[0];
dp[0] = i;
for (let j = 1; j <= n; j++) {
const temp = dp[j];
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
prev = temp;
}
}
return dp[n];
}
/**
* Calculate similarity ratio based on Levenshtein distance
*/
private simRatio(a: string, b: string): number {
if (!a && !b) return 1;
if (!a || !b) return 0;
const dist = this.levenshtein(a, b);
const maxLen = Math.max(a.length, b.length);
return maxLen ? 1 - dist / maxLen : 1;
}
/**
* Split artists string
*/
splitArtists(raw: string): string[] {
return raw
.split(/[,&+]|(?:\s+(?:feat\.?|featuring|with|vs\.?|and|x)\s+)/i)
.map((s) => s.trim())
.filter(Boolean);
}
/**
* Score title guess
*/
scoreTitle(guess: string, correct: string): ScoreResult {
if (!guess || !correct) {
return { score: 0, match: false };
}
const g = this.normalizeTitle(guess);
const c = this.normalizeTitle(correct);
// Exact match
if (g === c) {
return { score: 1.0, match: true };
}
// Try without optional segments
const gOpt = this.normalizeTitleBaseOptional(guess);
const cOpt = this.normalizeTitleBaseOptional(correct);
if (gOpt === cOpt && gOpt.length > 0) {
return { score: 0.98, match: true };
}
// Fuzzy matching
const jac = this.jaccard(g, c);
const sim = this.simRatio(g, c);
const score = 0.6 * jac + 0.4 * sim;
// Accept if score >= 0.6 (softened threshold)
return { score, match: score >= 0.6 };
}
/**
* Score artist guess
*/
scoreArtist(guess: string, correct: string): ScoreResult {
if (!guess || !correct) {
return { score: 0, match: false };
}
const guessParts = this.splitArtists(guess);
const correctParts = this.splitArtists(correct);
const gNorm = guessParts.map((p) => this.normalizeArtist(p));
const cNorm = correctParts.map((p) => this.normalizeArtist(p));
// Check if any guess part matches any correct part
let bestScore = 0;
for (const gPart of gNorm) {
for (const cPart of cNorm) {
if (gPart === cPart) {
return { score: 1.0, match: true };
}
const jac = this.jaccard(gPart, cPart);
const sim = this.simRatio(gPart, cPart);
const score = 0.6 * jac + 0.4 * sim;
bestScore = Math.max(bestScore, score);
}
}
// Accept if score >= 0.6 (softened threshold)
return { score: bestScore, match: bestScore >= 0.6 };
}
/**
* Score year guess
*/
scoreYear(guess: number | string, correct: number | null): ScoreResult {
if (correct === null) {
return { score: 0, match: false };
}
const guessNum = typeof guess === 'string' ? parseInt(guess, 10) : guess;
if (isNaN(guessNum)) {
return { score: 0, match: false };
}
if (guessNum === correct) {
return { score: 1.0, match: true };
}
const diff = Math.abs(guessNum - correct);
// Accept within 1 year
if (diff <= 1) {
return { score: 0.9, match: true };
}
// Within 2 years - partial credit but no match
if (diff <= 2) {
return { score: 0.7, match: false };
}
return { score: 0, match: false };
}
}