@jaysonder/tts-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { generateRoomCode } from '../../common/utils/room-code.js';
3
+ import { GameSettingsSchema, PlayerNameSchema } from '@nemsae/tts-validation';
4
+ import type { ZodIssue } from 'zod';
5
+ import type { Room, Player, GameSettings, GameState } from '../../common/types/index.js';
6
+
7
+ @Injectable()
8
+ export class RoomManagerService {
9
+ private readonly logger = new Logger(RoomManagerService.name);
10
+ private rooms = new Map<string, Room>();
11
+
12
+ createRoom(hostName: string, settings: GameSettings): Room {
13
+ const nameResult = PlayerNameSchema.safeParse(hostName);
14
+ if (!nameResult.success) {
15
+ const error = nameResult.error.issues.map((e: ZodIssue) => e.message).join(', ');
16
+ this.logger.error(`Invalid host name provided, hostName: ${hostName.substring(0, 20)}, error: ${error}`);
17
+ throw new Error(`Invalid host name: ${error}`);
18
+ }
19
+ const sanitizedHostName = nameResult.data;
20
+
21
+ const settingsResult = GameSettingsSchema.safeParse(settings);
22
+ if (!settingsResult.success) {
23
+ const errors = settingsResult.error.issues.map((e: ZodIssue) => e.message).join(', ');
24
+ this.logger.error(`Invalid game settings provided, errors: ${errors}, settings: ${JSON.stringify(settings)}`);
25
+ throw new Error(`Invalid game settings: ${errors}`);
26
+ }
27
+
28
+ const roomCode = this.generateUniqueCode();
29
+ const hostId = `host-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
30
+
31
+ this.logger.debug(`Creating room, roomCode: ${roomCode}, hostName: ${sanitizedHostName}, settings: ${JSON.stringify(settings)}`);
32
+
33
+ const host: Player = {
34
+ id: hostId,
35
+ name: sanitizedHostName,
36
+ isHost: true,
37
+ isReady: true,
38
+ currentScore: 0,
39
+ isConnected: true,
40
+ };
41
+
42
+ const game: GameState = {
43
+ roomCode,
44
+ settings,
45
+ players: [host],
46
+ twisters: [],
47
+ currentRound: -1,
48
+ roundResults: [],
49
+ status: 'lobby',
50
+ startedAt: null,
51
+ pausedAt: null,
52
+ totalPausedTime: 0,
53
+ currentTwisterStartTime: null,
54
+ roundTimeLimit: null,
55
+ };
56
+
57
+ const room: Room = {
58
+ code: roomCode,
59
+ game,
60
+ hostId,
61
+ createdAt: Date.now(),
62
+ };
63
+
64
+ this.rooms.set(roomCode, room);
65
+ this.logger.log(`Room created, roomCode: ${roomCode}, hostId: ${hostId}, totalRooms: ${this.rooms.size}`);
66
+ return room;
67
+ }
68
+
69
+ joinRoom(roomCode: string, playerName: string): { room: Room; player: Player } | null {
70
+ const nameResult = PlayerNameSchema.safeParse(playerName);
71
+ if (!nameResult.success) {
72
+ this.logger.warn(`Join failed - invalid player name, playerName: ${playerName.substring(0, 20)}, error: ${nameResult.error.issues.map((e: ZodIssue) => e.message).join(', ')}`);
73
+ return null;
74
+ }
75
+ const sanitizedPlayerName = nameResult.data;
76
+
77
+ this.logger.debug(`Attempting to join room, roomCode: ${roomCode}, playerName: ${sanitizedPlayerName}`);
78
+
79
+ const room = this.rooms.get(roomCode.toUpperCase());
80
+ if (!room) {
81
+ this.logger.warn(`Join failed - room not found, roomCode: ${roomCode}`);
82
+ return null;
83
+ }
84
+ if (room.game.players.length >= 4) {
85
+ this.logger.warn(`Join failed - room full, roomCode: ${roomCode}, currentPlayers: ${room.game.players.length}`);
86
+ return null;
87
+ }
88
+ if (room.game.status !== 'lobby') {
89
+ this.logger.warn(`Join failed - game already started, roomCode: ${roomCode}, status: ${room.game.status}`);
90
+ return null;
91
+ }
92
+
93
+ const player: Player = {
94
+ id: `player-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
95
+ name: sanitizedPlayerName,
96
+ isHost: false,
97
+ isReady: false,
98
+ currentScore: 0,
99
+ isConnected: true,
100
+ };
101
+
102
+ room.game.players.push(player);
103
+ this.logger.log(`Player joined room, roomCode: ${roomCode}, playerId: ${player.id}, playerName: ${playerName}, totalPlayers: ${room.game.players.length}`);
104
+ return { room, player };
105
+ }
106
+
107
+ getRoom(roomCode: string): Room | undefined {
108
+ return this.rooms.get(roomCode.toUpperCase());
109
+ }
110
+
111
+ removePlayer(roomCode: string, playerId: string): boolean {
112
+ const room = this.rooms.get(roomCode);
113
+ if (!room) return false;
114
+
115
+ const player = room.game.players.find((p: Player) => p.id === playerId);
116
+ this.logger.debug(`Removing player, roomCode: ${roomCode}, playerId: ${playerId}, playerName: ${player?.name}`);
117
+
118
+ room.game.players = room.game.players.filter((p: Player) => p.id !== playerId);
119
+
120
+ if (room.game.players.length === 0) {
121
+ this.rooms.delete(roomCode);
122
+ this.logger.log(`Room deleted - no players remaining, roomCode: ${roomCode}, totalRooms: ${this.rooms.size}`);
123
+ return true;
124
+ }
125
+
126
+ if (room.hostId === playerId && room.game.players.length > 0) {
127
+ room.game.players[0].isHost = true;
128
+ room.hostId = room.game.players[0].id;
129
+ this.logger.log(`Host reassigned, roomCode: ${roomCode}, newHostId: ${room.hostId}, newHostName: ${room.game.players[0].name}`);
130
+ }
131
+
132
+ return true;
133
+ }
134
+
135
+ getActiveLobbyPlayerCount(): number {
136
+ let count = 0;
137
+ for (const room of this.rooms.values()) {
138
+ if (room.game.status === 'lobby') {
139
+ count += room.game.players.length;
140
+ }
141
+ }
142
+ return count;
143
+ }
144
+
145
+ private generateUniqueCode(): string {
146
+ let code: string;
147
+ do {
148
+ code = generateRoomCode();
149
+ } while (this.rooms.has(code));
150
+ return code;
151
+ }
152
+ }
@@ -0,0 +1,46 @@
1
+ export function levenshteinDistance(source: string, target: string): number {
2
+ if (source === target) return 0;
3
+ if (source.length === 0) return target.length;
4
+ if (target.length === 0) return source.length;
5
+
6
+ const matrix = Array.from({ length: source.length + 1 }, () => new Array<number>(target.length + 1).fill(0));
7
+
8
+ for (let i = 0; i <= source.length; i++) matrix[i][0] = i;
9
+ for (let j = 0; j <= target.length; j++) matrix[0][j] = j;
10
+
11
+ for (let i = 1; i <= source.length; i++) {
12
+ for (let j = 1; j <= target.length; j++) {
13
+ const cost = source[i - 1] === target[j - 1] ? 0 : 1;
14
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
15
+ }
16
+ }
17
+
18
+ return matrix[source.length][target.length];
19
+ }
20
+
21
+ function normalizeText(value: string): string {
22
+ return value
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9\s]/g, ' ')
25
+ .replace(/\s+/g, ' ')
26
+ .trim();
27
+ }
28
+
29
+ export function scoreTwister(spoken: string, target: string): { similarity: number } {
30
+ const normalizedTarget = normalizeText(target);
31
+ const normalizedInput = normalizeText(spoken);
32
+
33
+ if (normalizedTarget.length === 0 && normalizedInput.length === 0) {
34
+ return { similarity: 100 };
35
+ }
36
+
37
+ if (normalizedTarget.length === 0 || normalizedInput.length === 0) {
38
+ return { similarity: 0 };
39
+ }
40
+
41
+ const distance = levenshteinDistance(normalizedTarget, normalizedInput);
42
+ const longestLength = Math.max(normalizedTarget.length, normalizedInput.length);
43
+ const rawScore = ((longestLength - distance) / longestLength) * 100;
44
+
45
+ return { similarity: Math.max(0, Math.round(rawScore)) };
46
+ }
@@ -0,0 +1,119 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import OpenAI from 'openai';
3
+ import { TopicSchema, RoundsSchema, CustomLengthSchema } from '@nemsae/tts-validation';
4
+ import type { ZodIssue } from 'zod';
5
+ import type { Twister, TwisterLength, TwisterTopic } from '../../common/types/index.js';
6
+
7
+ function getLengthInstruction(length: TwisterLength, customLength?: number): string {
8
+ if (length === 'custom' && customLength) {
9
+ return `Each tongue twister must be exactly ${customLength} words long.`;
10
+ }
11
+ const lengthMap: Record<'short' | 'medium' | 'long', string> = {
12
+ short: 'Keep each tongue twister very brief, around 5 words.',
13
+ medium: 'Make each tongue twister moderately long, around 10 words.',
14
+ long: 'Make each tongue twister quite lengthy, around 20 words.',
15
+ };
16
+ return lengthMap[length as 'short' | 'medium' | 'long'];
17
+ }
18
+
19
+ @Injectable()
20
+ export class TwisterGeneratorService {
21
+ private readonly logger = new Logger(TwisterGeneratorService.name);
22
+ private openai: OpenAI;
23
+
24
+ constructor() {
25
+ this.openai = new OpenAI({
26
+ apiKey: process.env.OPENAI_API_KEY,
27
+ });
28
+ }
29
+
30
+ async generateTwisters(
31
+ topic: TwisterTopic,
32
+ length: TwisterLength,
33
+ customLength: number | undefined,
34
+ rounds: number,
35
+ ): Promise<Twister[]> {
36
+ const topicResult = TopicSchema.safeParse(topic);
37
+ if (!topicResult.success) {
38
+ const error = topicResult.error.issues.map((e: ZodIssue) => e.message).join(', ');
39
+ this.logger.error(`Invalid topic provided, original: ${topic.substring(0, 50)}, error: ${error}`);
40
+ throw new Error(`Invalid topic: ${error}`);
41
+ }
42
+ const sanitizedTopic = topicResult.data;
43
+
44
+ const roundsResult = RoundsSchema.safeParse(rounds);
45
+ if (!roundsResult.success) {
46
+ const error = roundsResult.error.issues.map((e: ZodIssue) => e.message).join(', ');
47
+ this.logger.error(`Invalid rounds provided, original: ${rounds}, error: ${error}`);
48
+ throw new Error(`Invalid rounds: ${error}`);
49
+ }
50
+ const validatedRounds = roundsResult.data;
51
+
52
+ let validatedCustomLength: number | undefined = undefined;
53
+ if (length === 'custom' && customLength !== undefined) {
54
+ const customLengthResult = CustomLengthSchema.safeParse(customLength);
55
+ if (!customLengthResult.success) {
56
+ const error = customLengthResult.error.issues.map((e: ZodIssue) => e.message).join(', ');
57
+ this.logger.error(`Invalid custom length provided, original: ${customLength}, error: ${error}`);
58
+ throw new Error(`Invalid custom length: ${error}`);
59
+ }
60
+ validatedCustomLength = customLengthResult.data;
61
+ }
62
+
63
+ const lengthInstruction = getLengthInstruction(length, validatedCustomLength);
64
+
65
+ this.logger.log(`Generating twisters, topic: ${sanitizedTopic}, length: ${length}, customLength: ${validatedCustomLength}, rounds: ${validatedRounds}`);
66
+
67
+ const systemPrompt = `You are a tongue twister generator. Generate ${validatedRounds} unique, fun, and challenging tongue twisters that are difficult to say quickly.
68
+ Each tongue twister should feature words related to the topic: ${sanitizedTopic}.
69
+ ${lengthInstruction}
70
+ Return only the tongue twisters, one per line, with no numbering, no explanations, and no additional text.`;
71
+
72
+ try {
73
+ const response = await this.openai.chat.completions.create({
74
+ model: 'o3-mini',
75
+ messages: [
76
+ { role: 'system', content: systemPrompt },
77
+ { role: 'user', content: `Generate ${validatedRounds} unique tongue twisters about ${sanitizedTopic}.` },
78
+ ],
79
+ reasoning_effort: 'low',
80
+ });
81
+
82
+ const content = response.choices[0]?.message?.content?.trim() ?? '';
83
+
84
+ this.logger.debug(`OpenAI response: ${content.substring(0, 100)}`);
85
+
86
+ const texts = content
87
+ .split('\n')
88
+ .map((line: string) => line.trim())
89
+ .filter((line: string) => line.length > 0);
90
+
91
+ const usedTexts = new Set<string>();
92
+
93
+ const twisters = texts
94
+ .filter((text: string) => {
95
+ const normalized = text.toLowerCase();
96
+ if (usedTexts.has(normalized)) return false;
97
+ usedTexts.add(normalized);
98
+ return true;
99
+ })
100
+ .map((text: string, index: number) => {
101
+ const difficulty: 1 | 2 | 3 = length === 'short' ? 1 : length === 'medium' ? 2 : 3;
102
+ return {
103
+ id: `ai-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`,
104
+ text,
105
+ difficulty,
106
+ topic: sanitizedTopic,
107
+ length,
108
+ };
109
+ });
110
+
111
+ this.logger.log(`Twisters generated, count: ${twisters.length}, texts: ${JSON.stringify(twisters.map((t: Twister) => t.text))}`);
112
+
113
+ return twisters;
114
+ } catch (error) {
115
+ this.logger.error(`Failed to generate twisters, error: ${error instanceof Error ? error.message : String(error)}`);
116
+ throw error;
117
+ }
118
+ }
119
+ }
package/src/main.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { Logger } from '@nestjs/common';
2
+ import 'dotenv/config';
3
+ import { NestFactory } from '@nestjs/core';
4
+ import { ConfigService } from '@nestjs/config';
5
+ import { AppModule } from './app.module.js';
6
+
7
+ const logger = new Logger('Bootstrap');
8
+
9
+ async function bootstrap() {
10
+ const app = await NestFactory.create(AppModule);
11
+
12
+ app.enableCors({
13
+ origin: app.get(ConfigService).get<string>('CLIENT_URL'),
14
+ credentials: true,
15
+ });
16
+
17
+ const port = app.get(ConfigService).get<number>('PORT') || 3001;
18
+ await app.listen(port);
19
+ logger.log(`Application running on port ${port}`);
20
+ }
21
+
22
+ void bootstrap();
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "nodenext",
4
+ "moduleResolution": "nodenext",
5
+ "target": "ES2022",
6
+ "strict": true,
7
+ "strictPropertyInitialization": false,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "outDir": "dist",
11
+ "rootDir": "src",
12
+ "experimentalDecorators": true,
13
+ "emitDecoratorMetadata": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }