speech synthesis
This commit is contained in:
parent
8d2f86b3f8
commit
09e327e19a
9 changed files with 577 additions and 169 deletions
|
|
@ -36,6 +36,8 @@ type BaseQuestion = {
|
|||
points: number;
|
||||
song?: Song;
|
||||
hideSongTitle?: boolean;
|
||||
questionKey?: string;
|
||||
subjectKey?: string;
|
||||
};
|
||||
|
||||
export type Question =
|
||||
|
|
|
|||
208
api/src/party/__tests__/question-generation.test.ts
Normal file
208
api/src/party/__tests__/question-generation.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Question, QuizRound } from "../../party-types";
|
||||
import { buildNumericQuestion } from "../numeric-question-generator";
|
||||
import {
|
||||
buildMemberOptions,
|
||||
type PartyAnalytics,
|
||||
type PartyQuestionMember,
|
||||
pickQuestionCandidate,
|
||||
} from "../question-utils";
|
||||
import { buildSocialQuestion } from "../social-question-generator";
|
||||
|
||||
type Db = typeof import("../../db").db;
|
||||
|
||||
function makeChoiceQuestion(
|
||||
text: string,
|
||||
key: string,
|
||||
subjectKey: string,
|
||||
): Question {
|
||||
return {
|
||||
type: "choice",
|
||||
text,
|
||||
correct: 0,
|
||||
startTimestamp: 1,
|
||||
endTimestamp: 2,
|
||||
points: 10,
|
||||
options: ["A", "B"],
|
||||
questionKey: key,
|
||||
subjectKey,
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeDb(trackReleaseDate: Date | null) {
|
||||
const trackRecord = {
|
||||
id: "track-1",
|
||||
name: "Shared Track",
|
||||
album: {
|
||||
name: "Shared Album",
|
||||
release_date: trackReleaseDate,
|
||||
},
|
||||
artists: [{ id: "artist-1", name: "Shared Artist" }],
|
||||
};
|
||||
|
||||
return {
|
||||
query: {
|
||||
track: {
|
||||
findFirst: vi.fn(async () => trackRecord),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
artist: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: "artist-1",
|
||||
name: "Shared Artist",
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: vi.fn(() => ({
|
||||
from: () => ({
|
||||
where: async () => [],
|
||||
}),
|
||||
})),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
describe("question generation", () => {
|
||||
it("skips repeated question keys, subjects, and text", () => {
|
||||
const history: QuizRound[] = [
|
||||
{
|
||||
questionIndex: 0,
|
||||
question: {
|
||||
...makeChoiceQuestion(
|
||||
"Which genre appears most in the party analytics?",
|
||||
"audio:genre:pop",
|
||||
"genre:pop",
|
||||
),
|
||||
},
|
||||
responses: [],
|
||||
},
|
||||
];
|
||||
|
||||
const question = pickQuestionCandidate(
|
||||
[
|
||||
{
|
||||
key: "audio:genre:pop",
|
||||
subjectKey: "genre:pop",
|
||||
question: makeChoiceQuestion(
|
||||
"Which genre appears most in the party analytics?",
|
||||
"audio:genre:pop",
|
||||
"genre:pop",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "audio:artist:abba",
|
||||
subjectKey: "artist:abba",
|
||||
question: makeChoiceQuestion(
|
||||
"Which artist shows up most often in the shared audio data?",
|
||||
"audio:artist:abba",
|
||||
"artist:abba",
|
||||
),
|
||||
},
|
||||
],
|
||||
history,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(question?.text).toBe(
|
||||
"Which artist shows up most often in the shared audio data?",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when member options would require fake placeholders", () => {
|
||||
const members: PartyQuestionMember[] = [
|
||||
{ userId: "a", name: "Sam" },
|
||||
{ userId: "b", name: "Sam" },
|
||||
];
|
||||
|
||||
const correctMember = members[0];
|
||||
expect(correctMember).toBeDefined();
|
||||
if (correctMember) {
|
||||
expect(buildMemberOptions(correctMember, members)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips numeric questions with missing release dates and zero counts", async () => {
|
||||
const db = createFakeDb(null);
|
||||
const analytics = {
|
||||
storyClusters: [
|
||||
{
|
||||
tracks: [
|
||||
{
|
||||
name: "Shared Track",
|
||||
artists: [{ name: "Shared Artist" }],
|
||||
albumName: "Shared Album",
|
||||
},
|
||||
],
|
||||
artists: [{ name: "Shared Artist" }],
|
||||
genres: [],
|
||||
},
|
||||
],
|
||||
groupSummary: {
|
||||
mostSharedGenres: [],
|
||||
},
|
||||
} as PartyAnalytics;
|
||||
const members: PartyQuestionMember[] = [
|
||||
{ userId: "a", name: "A" },
|
||||
{ userId: "b", name: "B" },
|
||||
];
|
||||
const history: QuizRound[] = [
|
||||
{
|
||||
questionIndex: 0,
|
||||
question: {
|
||||
type: "numeric",
|
||||
text: "What's the release year of Shared Album?",
|
||||
correct: 2010,
|
||||
startTimestamp: 1,
|
||||
endTimestamp: 2,
|
||||
points: 10,
|
||||
range: { min: 2000, max: 2010 },
|
||||
questionKey: "numeric:album-year:Shared Album",
|
||||
subjectKey: "album:Shared Album",
|
||||
},
|
||||
responses: [],
|
||||
},
|
||||
];
|
||||
|
||||
const question = await buildNumericQuestion({
|
||||
db,
|
||||
analytics,
|
||||
index: 0,
|
||||
members,
|
||||
history,
|
||||
});
|
||||
|
||||
expect(question).toBeNull();
|
||||
});
|
||||
|
||||
it("skips social fallback names for duplicate members", async () => {
|
||||
const db = createFakeDb(null);
|
||||
const members: PartyQuestionMember[] = [
|
||||
{ userId: "a", name: "Sam" },
|
||||
{ userId: "b", name: "Sam" },
|
||||
];
|
||||
const quizState = {
|
||||
status: "running" as const,
|
||||
workflowId: null,
|
||||
questionIndex: 0,
|
||||
currentQuestion: null,
|
||||
answers: {},
|
||||
scores: { a: 3, b: 1 },
|
||||
history: [],
|
||||
};
|
||||
|
||||
const question = await buildSocialQuestion(
|
||||
db,
|
||||
quizState,
|
||||
{
|
||||
groupSummary: {
|
||||
mostDiverseMember: { userId: "a", genreEntropy: 1 },
|
||||
mostSharedGenres: [],
|
||||
mostAlignedPair: null,
|
||||
},
|
||||
},
|
||||
members,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(question).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { db as Db } from "../db";
|
||||
import type { Question } from "../party-types";
|
||||
import type { Question, QuizRound } from "../party-types";
|
||||
import {
|
||||
buildOptionsWithCorrect,
|
||||
buildOrderedOptions,
|
||||
|
|
@ -9,7 +9,9 @@ import {
|
|||
getTopClusterTracks,
|
||||
isUsableText,
|
||||
type PartyAnalytics,
|
||||
pickQuestionCandidate,
|
||||
pickRandom,
|
||||
type QuestionCandidate,
|
||||
resolveQuestionSong,
|
||||
} from "./question-utils";
|
||||
|
||||
|
|
@ -17,10 +19,11 @@ export async function buildAudioMetadataQuestion(
|
|||
dbClient: typeof Db,
|
||||
analytics: PartyAnalytics,
|
||||
index: number,
|
||||
history: QuizRound[],
|
||||
): Promise<Question | null> {
|
||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||
const questions: Array<
|
||||
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
||||
QuestionCandidate<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">>
|
||||
> = [];
|
||||
const topSong = await resolveQuestionSong(dbClient, analytics);
|
||||
const topSongName = topSong?.name;
|
||||
|
|
@ -34,13 +37,17 @@ export async function buildAudioMetadataQuestion(
|
|||
);
|
||||
if (currentSongOptions) {
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "What song is currently playing?",
|
||||
options: currentSongOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
hideSongTitle: true,
|
||||
key: `audio:current-song:${topSongName}`,
|
||||
subjectKey: `track:${topSongName}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: "What song is currently playing?",
|
||||
options: currentSongOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
hideSongTitle: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -50,14 +57,21 @@ export async function buildAudioMetadataQuestion(
|
|||
4,
|
||||
);
|
||||
if (genreOptions) {
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "Which genre appears most in the party analytics?",
|
||||
options: genreOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
});
|
||||
const topGenre = genreOptions[0];
|
||||
if (topGenre) {
|
||||
questions.push({
|
||||
key: `audio:genre:${topGenre}`,
|
||||
subjectKey: `genre:${topGenre}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: "Which genre appears most in the party analytics?",
|
||||
options: genreOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const topArtists = getTopClusterArtists(analytics);
|
||||
|
|
@ -66,12 +80,16 @@ export async function buildAudioMetadataQuestion(
|
|||
const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4);
|
||||
if (artistOptions) {
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "Which artist shows up most often in the shared audio data?",
|
||||
options: artistOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
key: `audio:artist:${topArtist}`,
|
||||
subjectKey: `artist:${topArtist}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: "Which artist shows up most often in the shared audio data?",
|
||||
options: artistOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -83,12 +101,16 @@ export async function buildAudioMetadataQuestion(
|
|||
: null;
|
||||
if (trackOptions) {
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "Which track looks most shared across the party?",
|
||||
options: trackOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
key: `audio:track:${topTrackName}`,
|
||||
subjectKey: `track:${topTrackName}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: "Which track looks most shared across the party?",
|
||||
options: trackOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -112,12 +134,16 @@ export async function buildAudioMetadataQuestion(
|
|||
);
|
||||
if (artistOptions) {
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: `Who performs "${randomTopTrack.name}"?`,
|
||||
options: artistOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
key: `audio:performer:${randomTopTrack.name}`,
|
||||
subjectKey: `track:${randomTopTrack.name}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: `Who performs "${randomTopTrack.name}"?`,
|
||||
options: artistOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -129,13 +155,16 @@ export async function buildAudioMetadataQuestion(
|
|||
);
|
||||
if (trackNameOptions) {
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: `What is the name of this track by ${correctArtist}?`,
|
||||
options: trackNameOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
hideSongTitle: true,
|
||||
key: `audio:title:${randomTopTrack.name}`,
|
||||
subjectKey: `track:${randomTopTrack.name}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: `What is the name of this track by ${correctArtist}?`,
|
||||
options: trackNameOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -147,13 +176,17 @@ export async function buildAudioMetadataQuestion(
|
|||
);
|
||||
if (alternateSongOptions) {
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "Which song is this audio clip from?",
|
||||
options: alternateSongOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
hideSongTitle: true,
|
||||
key: `audio:current-song:${topSongName}`,
|
||||
subjectKey: `track:${topSongName}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: "Which song is this audio clip from?",
|
||||
options: alternateSongOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
hideSongTitle: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -170,12 +203,16 @@ export async function buildAudioMetadataQuestion(
|
|||
);
|
||||
if (albumOptions) {
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: `"${randomTopTrack.name}" appears on which album?`,
|
||||
options: albumOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
key: `audio:album:${randomTopTrack.albumName}`,
|
||||
subjectKey: `track:${randomTopTrack.name}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: `"${randomTopTrack.name}" appears on which album?`,
|
||||
options: albumOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -185,7 +222,7 @@ export async function buildAudioMetadataQuestion(
|
|||
return null;
|
||||
}
|
||||
|
||||
const question = questions[index % questions.length];
|
||||
if (!question) throw new Error("Question not found");
|
||||
const question = pickQuestionCandidate(questions, history, index);
|
||||
if (!question) return null;
|
||||
return buildQuestionWindow(question);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ import {
|
|||
topArtist as topArtistTable,
|
||||
topTrack as topTrackTable,
|
||||
} from "../db/schema";
|
||||
import type { Question } from "../party-types";
|
||||
import type { Question, QuizRound } from "../party-types";
|
||||
import {
|
||||
buildQuestionWindow,
|
||||
getReleaseYearRange,
|
||||
isUsableText,
|
||||
type PartyAnalytics,
|
||||
type PartyQuestionMember,
|
||||
pickQuestionCandidate,
|
||||
type QuestionCandidate,
|
||||
resolveQuestionSong,
|
||||
} from "./question-utils";
|
||||
|
||||
|
|
@ -24,12 +26,12 @@ type BuildNumericQuestionInput = {
|
|||
analytics: PartyAnalytics;
|
||||
index: number;
|
||||
members: PartyQuestionMember[];
|
||||
history: QuizRound[];
|
||||
};
|
||||
|
||||
async function getAlbumReleaseYear({
|
||||
db,
|
||||
analytics,
|
||||
index,
|
||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||
const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name;
|
||||
const track = trackName
|
||||
|
|
@ -38,16 +40,15 @@ async function getAlbumReleaseYear({
|
|||
with: { album: true },
|
||||
})
|
||||
: null;
|
||||
const song = await resolveQuestionSong(db, analytics, {
|
||||
trackName: track?.name ?? trackName ?? undefined,
|
||||
});
|
||||
const subject = [track?.album?.name, track?.name].find((value) =>
|
||||
isUsableText(value),
|
||||
);
|
||||
if (!subject) return null;
|
||||
const correct =
|
||||
track?.album?.release_date?.getFullYear() ??
|
||||
new Date().getFullYear() - 1 - index;
|
||||
if (!track?.album?.release_date) return null;
|
||||
const song = await resolveQuestionSong(db, analytics, {
|
||||
trackName: track?.name ?? trackName ?? undefined,
|
||||
});
|
||||
const correct = track.album.release_date.getFullYear();
|
||||
return {
|
||||
type: "numeric",
|
||||
text: `What's the release year of ${subject}?`,
|
||||
|
|
@ -55,6 +56,8 @@ async function getAlbumReleaseYear({
|
|||
range: getReleaseYearRange(correct),
|
||||
points: 10,
|
||||
song: song ?? undefined,
|
||||
questionKey: `numeric:album-year:${subject}`,
|
||||
subjectKey: `album:${subject}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +84,7 @@ async function countTopTrackListeners({
|
|||
),
|
||||
);
|
||||
const correct = new Set(entries.map((e) => e.userId)).size;
|
||||
if (correct <= 0) return null;
|
||||
return {
|
||||
type: "numeric",
|
||||
text: `For how many players in the party is "${trackName}" a top track?`,
|
||||
|
|
@ -88,6 +92,8 @@ async function countTopTrackListeners({
|
|||
range: { min: 0, max: members.length },
|
||||
points: 10,
|
||||
song: song ?? undefined,
|
||||
questionKey: `numeric:top-track-count:${trackName}`,
|
||||
subjectKey: `track:${trackName}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +122,7 @@ async function countFavouriteArtistListeners({
|
|||
),
|
||||
);
|
||||
const correct = new Set(entries.map((e) => e.userId)).size;
|
||||
if (correct <= 0) return null;
|
||||
return {
|
||||
type: "numeric",
|
||||
text: `How many players in the party have "${artistName}" as a favourite artist?`,
|
||||
|
|
@ -123,24 +130,44 @@ async function countFavouriteArtistListeners({
|
|||
range: { min: 0, max: members.length },
|
||||
points: 10,
|
||||
song: song ?? undefined,
|
||||
questionKey: `numeric:artist-count:${artistName}`,
|
||||
subjectKey: `artist:${artistName}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildNumericQuestion(
|
||||
input: BuildNumericQuestionInput,
|
||||
): Promise<Question | null> {
|
||||
const questions: NumericQuestion[] = [];
|
||||
const questions: Array<QuestionCandidate<NumericQuestion>> = [];
|
||||
|
||||
const albumYearQ = await getAlbumReleaseYear(input);
|
||||
if (albumYearQ) questions.push(albumYearQ);
|
||||
if (albumYearQ) {
|
||||
questions.push({
|
||||
key: albumYearQ.questionKey ?? `numeric:album-year:${albumYearQ.text}`,
|
||||
subjectKey: albumYearQ.subjectKey,
|
||||
question: albumYearQ,
|
||||
});
|
||||
}
|
||||
|
||||
const topTrackQ = await countTopTrackListeners(input);
|
||||
if (topTrackQ) questions.push(topTrackQ);
|
||||
if (topTrackQ) {
|
||||
questions.push({
|
||||
key: topTrackQ.questionKey ?? `numeric:top-track-count:${topTrackQ.text}`,
|
||||
subjectKey: topTrackQ.subjectKey,
|
||||
question: topTrackQ,
|
||||
});
|
||||
}
|
||||
|
||||
const artistQ = await countFavouriteArtistListeners(input);
|
||||
if (artistQ) questions.push(artistQ);
|
||||
if (artistQ) {
|
||||
questions.push({
|
||||
key: artistQ.questionKey ?? `numeric:artist-count:${artistQ.text}`,
|
||||
subjectKey: artistQ.subjectKey,
|
||||
question: artistQ,
|
||||
});
|
||||
}
|
||||
|
||||
const question = questions[input.index % questions.length] ?? questions[0];
|
||||
const question = pickQuestionCandidate(questions, input.history, input.index);
|
||||
if (!question) return null;
|
||||
return buildQuestionWindow(question);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Question, QuizState } from "../party-types";
|
|||
import { buildAudioMetadataQuestion } from "./audio-question-generator";
|
||||
import { buildNumericQuestion } from "./numeric-question-generator";
|
||||
import type { PartyAnalytics } from "./question-utils";
|
||||
import { fetchPartyMembers } from "./question-utils";
|
||||
import { buildQuestionWindow, fetchPartyMembers } from "./question-utils";
|
||||
import { buildSocialQuestion } from "./social-question-generator";
|
||||
|
||||
export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
|
||||
|
|
@ -24,21 +24,58 @@ export async function generatePartyQuestion({
|
|||
index,
|
||||
}: GenerateQuestionInput): Promise<Question | null> {
|
||||
const members = await fetchPartyMembers(dbClient, partyId);
|
||||
const type: PartyQuestionType =
|
||||
index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social";
|
||||
const preferredOrder: PartyQuestionType[] = [
|
||||
"audio-metadata",
|
||||
"social",
|
||||
"numeric",
|
||||
];
|
||||
const rotation = index % preferredOrder.length;
|
||||
const typeOrder = [
|
||||
...preferredOrder.slice(rotation),
|
||||
...preferredOrder.slice(0, rotation),
|
||||
];
|
||||
|
||||
for (const type of typeOrder) {
|
||||
if (type === "audio-metadata") {
|
||||
const q = await buildAudioMetadataQuestion(
|
||||
dbClient,
|
||||
analytics,
|
||||
index,
|
||||
quizState.history,
|
||||
);
|
||||
if (q) return q;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "social") {
|
||||
const q = await buildSocialQuestion(
|
||||
dbClient,
|
||||
quizState,
|
||||
analytics,
|
||||
members,
|
||||
index,
|
||||
);
|
||||
if (q) return q;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "audio-metadata") {
|
||||
const q = await buildAudioMetadataQuestion(dbClient, analytics, index);
|
||||
if (q) return q;
|
||||
}
|
||||
if (type === "numeric") {
|
||||
const q = await buildNumericQuestion({
|
||||
db: dbClient,
|
||||
analytics,
|
||||
index,
|
||||
members,
|
||||
history: quizState.history,
|
||||
});
|
||||
if (q) return q;
|
||||
}
|
||||
return buildSocialQuestion(dbClient, quizState, analytics, members, index);
|
||||
|
||||
return buildQuestionWindow({
|
||||
type: "numeric" as const,
|
||||
text: "How many players are in this party?",
|
||||
correct: members.length,
|
||||
range: { min: 0, max: members.length },
|
||||
points: 5,
|
||||
subjectKey: "party-size",
|
||||
questionKey: `fallback:party-size:${members.length}`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { db as Db } from "../db";
|
||||
import type { track as trackTable } from "../db/schema";
|
||||
import type { QuizRound } from "../party-types";
|
||||
|
||||
export type PartyQuestionMember = {
|
||||
userId: string;
|
||||
|
|
@ -9,7 +10,14 @@ export type PartyQuestionMember = {
|
|||
|
||||
export type PartyAnalytics = {
|
||||
groupSummary?: {
|
||||
totalMembers?: number;
|
||||
mostSharedGenres?: { name: string }[];
|
||||
mostDiverseMember?: { userId: string; genreEntropy: number } | null;
|
||||
mostAlignedPair?: {
|
||||
userIdA: string;
|
||||
userIdB: string;
|
||||
similarity?: number;
|
||||
} | null;
|
||||
};
|
||||
storyClusters?: {
|
||||
tracks?: {
|
||||
|
|
@ -31,6 +39,17 @@ export type AnalyticsTrack = {
|
|||
albumName?: string;
|
||||
memberScores?: { userId: string; score: number }[];
|
||||
};
|
||||
type QuestionLike = {
|
||||
text: string;
|
||||
questionKey?: string;
|
||||
subjectKey?: string;
|
||||
};
|
||||
|
||||
export type QuestionCandidate<T extends QuestionLike = QuestionLike> = {
|
||||
key: string;
|
||||
subjectKey?: string;
|
||||
question: T;
|
||||
};
|
||||
export type QuestionSong = InferSelectModel<typeof trackTable>;
|
||||
|
||||
export const QUESTION_DURATION_MS = 60_000;
|
||||
|
|
@ -113,6 +132,46 @@ export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
|
|||
);
|
||||
}
|
||||
|
||||
export function pickQuestionCandidate<T extends QuestionLike>(
|
||||
candidates: QuestionCandidate<T>[],
|
||||
history: QuizRound[],
|
||||
index: number,
|
||||
): T | null {
|
||||
const seenKeys = new Set<string>();
|
||||
const seenSubjects = new Set<string>();
|
||||
const seenTexts = new Set<string>();
|
||||
|
||||
for (const round of history) {
|
||||
const question = round.question;
|
||||
seenTexts.add(normalizeQuestionKey(question.text));
|
||||
if (question.questionKey)
|
||||
seenKeys.add(normalizeQuestionKey(question.questionKey));
|
||||
if (question.subjectKey)
|
||||
seenSubjects.add(normalizeQuestionKey(question.subjectKey));
|
||||
}
|
||||
|
||||
const fresh = candidates.filter((candidate) => {
|
||||
const key = normalizeQuestionKey(candidate.key);
|
||||
const subjectKey = candidate.subjectKey
|
||||
? normalizeQuestionKey(candidate.subjectKey)
|
||||
: null;
|
||||
const text = normalizeQuestionKey(candidate.question.text);
|
||||
|
||||
if (seenKeys.has(key)) return false;
|
||||
if (subjectKey && seenSubjects.has(subjectKey)) return false;
|
||||
if (seenTexts.has(text)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (fresh.length === 0) return null;
|
||||
const pool = fresh;
|
||||
return pool[index % pool.length]?.question ?? null;
|
||||
}
|
||||
|
||||
function normalizeQuestionKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
||||
return (analytics?.storyClusters?.[0]?.artists ?? []).map(
|
||||
(artist) => artist.name,
|
||||
|
|
@ -220,7 +279,7 @@ export function buildOrderedOptions(
|
|||
desiredCount: number,
|
||||
): string[] | null {
|
||||
const options = uniqueStrings(
|
||||
values.filter((value): value is string => Boolean(value)),
|
||||
values.filter((value): value is string => isUsableText(value)),
|
||||
);
|
||||
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
||||
}
|
||||
|
|
@ -230,9 +289,10 @@ export function buildOptionsWithCorrect(
|
|||
candidates: string[],
|
||||
desiredCount: number,
|
||||
): string[] | null {
|
||||
if (!isUsableText(correct)) return null;
|
||||
const options = uniqueStrings([
|
||||
correct,
|
||||
...candidates.filter((c) => c !== correct),
|
||||
...candidates.filter((c) => isUsableText(c) && c !== correct),
|
||||
]);
|
||||
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
||||
}
|
||||
|
|
@ -271,43 +331,33 @@ export function hasClearLeader(quizState: {
|
|||
export function getMostDiverseMember(
|
||||
analytics: PartyAnalytics,
|
||||
members: PartyQuestionMember[],
|
||||
): PartyQuestionMember {
|
||||
const userId = analytics?.memberProfiles?.[0]?.userId;
|
||||
return (
|
||||
members.find((member) => member.userId === userId) ??
|
||||
members[1] ??
|
||||
members[0] ?? { userId: "", name: "Player B" }
|
||||
);
|
||||
): PartyQuestionMember | null {
|
||||
const userId = analytics?.groupSummary?.mostDiverseMember?.userId;
|
||||
if (!userId) return null;
|
||||
return members.find((member) => member.userId === userId) ?? null;
|
||||
}
|
||||
|
||||
export function getMostAlignedMember(
|
||||
analytics: PartyAnalytics,
|
||||
members: PartyQuestionMember[],
|
||||
): PartyQuestionMember {
|
||||
const userId = analytics?.pairwise?.[0]?.userIdA;
|
||||
return (
|
||||
members.find((member) => member.userId === userId) ??
|
||||
members[2] ??
|
||||
members[0] ?? { userId: "", name: "Player C" }
|
||||
);
|
||||
): PartyQuestionMember | null {
|
||||
const userId = analytics?.groupSummary?.mostAlignedPair?.userIdA;
|
||||
if (!userId) return null;
|
||||
return members.find((member) => member.userId === userId) ?? null;
|
||||
}
|
||||
|
||||
export function buildMemberOptions(
|
||||
correctMember: PartyQuestionMember,
|
||||
members: PartyQuestionMember[],
|
||||
): string[] {
|
||||
): string[] | null {
|
||||
const desiredCount = getPartySize(members.length);
|
||||
if (!isUsableText(correctMember.name)) return null;
|
||||
const options = uniqueStrings([
|
||||
correctMember.name,
|
||||
...members.map((member) => member.name),
|
||||
...members.map((member) => member.name).filter(isUsableText),
|
||||
]);
|
||||
|
||||
if (options.length < desiredCount) {
|
||||
for (const fallback of fallbackPlayerNames(desiredCount)) {
|
||||
if (options.length >= desiredCount) break;
|
||||
if (!options.includes(fallback)) options.push(fallback);
|
||||
}
|
||||
}
|
||||
if (options.length < desiredCount) return null;
|
||||
|
||||
const ordered = [
|
||||
correctMember.name,
|
||||
|
|
@ -328,27 +378,23 @@ function normalizeRange(
|
|||
return { min: max, max: min };
|
||||
}
|
||||
|
||||
function fallbackPlayerNames(count: number): string[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `Player ${String.fromCharCode(65 + index)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMemberPairOptions(
|
||||
members: PartyQuestionMember[],
|
||||
correctPair: string,
|
||||
): string[] | null {
|
||||
if (members.length < 3) return null;
|
||||
const pairs: string[] = [correctPair];
|
||||
if (!isUsableText(correctPair)) return null;
|
||||
const pairs = uniqueStrings([correctPair]);
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
for (let j = i + 1; j < members.length; j++) {
|
||||
const left = members[i];
|
||||
const right = members[j];
|
||||
if (!left || !right) continue;
|
||||
if (!isUsableText(left.name) || !isUsableText(right.name)) continue;
|
||||
const pair = `${left.name} & ${right.name}`;
|
||||
if (pair !== correctPair) pairs.push(pair);
|
||||
}
|
||||
}
|
||||
return pairs.length >= 2 ? pairs.slice(0, 4) : null;
|
||||
const deduped = uniqueStrings(pairs);
|
||||
return deduped.length >= 2 ? deduped.slice(0, 4) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@ import {
|
|||
buildMemberPairOptions,
|
||||
buildQuestionWindow,
|
||||
getCurrentLeader,
|
||||
getMostAlignedMember,
|
||||
getMostDiverseMember,
|
||||
getTopClusterTracks,
|
||||
getTopTrackListener,
|
||||
hasClearLeader,
|
||||
type PartyAnalytics,
|
||||
type PartyQuestionMember,
|
||||
pickQuestionCandidate,
|
||||
pickRandom,
|
||||
type QuestionCandidate,
|
||||
resolveQuestionSong,
|
||||
} from "./question-utils";
|
||||
|
||||
|
|
@ -25,52 +26,49 @@ export async function buildSocialQuestion(
|
|||
): Promise<Question | null> {
|
||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||
const questions: Array<
|
||||
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
||||
QuestionCandidate<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">>
|
||||
> = [];
|
||||
const topSong = await resolveQuestionSong(dbClient, analytics);
|
||||
|
||||
const hasMultipleMembers = members.length >= 2;
|
||||
if (hasMultipleMembers && hasClearLeader(quizState)) {
|
||||
const leader = getCurrentLeader(quizState, members);
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "Who is leading the quiz right now?",
|
||||
options: buildMemberOptions(leader, members),
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
});
|
||||
const options = buildMemberOptions(leader, members);
|
||||
if (options) {
|
||||
questions.push({
|
||||
key: "social:leader",
|
||||
subjectKey: `member:${leader.userId}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: "Who is leading the quiz right now?",
|
||||
options,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMultipleMembers) {
|
||||
const diverse = getMostDiverseMember(analytics, members);
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "Who looks like the most diverse listener in the party?",
|
||||
options: buildMemberOptions(diverse, members),
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
});
|
||||
|
||||
const aligned = getMostAlignedMember(analytics, members);
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "Which member seems most aligned with the rest of the party?",
|
||||
options: buildMemberOptions(aligned, members),
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
});
|
||||
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "Who would you ask for a recommendation based on the party taste?",
|
||||
options: buildMemberOptions(aligned, members),
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
});
|
||||
if (diverse) {
|
||||
const options = buildMemberOptions(diverse, members);
|
||||
if (options) {
|
||||
questions.push({
|
||||
key: "social:diverse",
|
||||
subjectKey: `member:${diverse.userId}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: "Who looks like the most diverse listener in the party?",
|
||||
options,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topTracks = getTopClusterTracks(analytics);
|
||||
|
|
@ -83,32 +81,43 @@ export async function buildSocialQuestion(
|
|||
artistNames: randomTrack.artists?.map((artist) => artist.name),
|
||||
albumName: randomTrack.albumName,
|
||||
});
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: `Who is most likely to have "${randomTrack.name}" in heavy rotation?`,
|
||||
options: buildMemberOptions(topListener, members),
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
});
|
||||
const options = buildMemberOptions(topListener, members);
|
||||
if (options) {
|
||||
questions.push({
|
||||
key: `social:track-listener:${randomTrack.name}`,
|
||||
subjectKey: `track:${randomTrack.name}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: `Who listens the most to "${randomTrack.name}"?`,
|
||||
options,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (members.length >= 3 && analytics?.pairwise?.length) {
|
||||
const topPair = analytics.pairwise[0];
|
||||
const memberA = members.find((m) => m.userId === topPair?.userIdA);
|
||||
const memberB = members.find((m) => m.userId === topPair?.userIdB);
|
||||
if (members.length >= 3 && analytics?.groupSummary?.mostAlignedPair) {
|
||||
const topPair = analytics.groupSummary.mostAlignedPair;
|
||||
const memberA = members.find((m) => m.userId === topPair.userIdA);
|
||||
const memberB = members.find((m) => m.userId === topPair.userIdB);
|
||||
if (memberA && memberB) {
|
||||
const correctPair = `${memberA.name} & ${memberB.name}`;
|
||||
const pairOptions = buildMemberPairOptions(members, correctPair);
|
||||
if (pairOptions) {
|
||||
questions.push({
|
||||
type: "choice",
|
||||
text: "Which two players would probably agree on the aux?",
|
||||
options: pairOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
key: `social:pair:${memberA.userId}:${memberB.userId}`,
|
||||
subjectKey: `pair:${[memberA.userId, memberB.userId].sort().join("|")}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: "Which two players share the most musical taste?",
|
||||
options: pairOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -118,7 +127,7 @@ export async function buildSocialQuestion(
|
|||
return null;
|
||||
}
|
||||
|
||||
const question = questions[index % questions.length];
|
||||
if (!question) throw new Error("Question not found");
|
||||
const question = pickQuestionCandidate(questions, quizState.history, index);
|
||||
if (!question) return null;
|
||||
return buildQuestionWindow(question);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --watch index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
|
|||
import { useUser } from "#/hooks/user";
|
||||
import { client } from "#/lib/eden";
|
||||
|
||||
type PartyQuestion = NonNullable<
|
||||
NonNullable<ReturnType<typeof useParty>["party"]>["data"]["currentQuestion"]
|
||||
>;
|
||||
|
||||
function formatTimeLeft(milliseconds: number) {
|
||||
const clamped = Math.max(0, milliseconds);
|
||||
const totalSeconds = Math.ceil(clamped / 1000);
|
||||
|
|
@ -34,6 +38,17 @@ function formatTimeLeft(milliseconds: number) {
|
|||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function getQuestionAnnouncement(question: PartyQuestion) {
|
||||
if (question.type === "numeric") {
|
||||
return `${question.text}. Choose a number from ${question.range.min} to ${question.range.max}.`;
|
||||
}
|
||||
|
||||
const options = question.options
|
||||
.map((option, index) => `Option ${index + 1}: ${option}`)
|
||||
.join(". ");
|
||||
return `${question.text}. ${options}.`;
|
||||
}
|
||||
|
||||
export function Question() {
|
||||
const { party, members } = useParty();
|
||||
const { user } = useUser();
|
||||
|
|
@ -48,6 +63,10 @@ export function Question() {
|
|||
: null;
|
||||
const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } =
|
||||
useSpotifyPlayer(spotifyTrackUri);
|
||||
const questionStartTimestamp = question?.startTimestamp ?? null;
|
||||
const questionAnnouncement = question
|
||||
? getQuestionAnnouncement(question)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
|
|
@ -63,6 +82,26 @@ export function Question() {
|
|||
setSelectedValue(question.type === "numeric" ? question.range.min : null);
|
||||
}, [question]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!questionAnnouncement ||
|
||||
questionStartTimestamp == null ||
|
||||
!("speechSynthesis" in window)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(questionAnnouncement);
|
||||
utterance.rate = 0.95;
|
||||
utterance.pitch = 1;
|
||||
|
||||
window.speechSynthesis.cancel();
|
||||
window.speechSynthesis.speak(utterance);
|
||||
|
||||
return () => {
|
||||
window.speechSynthesis.cancel();
|
||||
};
|
||||
}, [questionAnnouncement, questionStartTimestamp]);
|
||||
if (!question)
|
||||
return (
|
||||
<Section>
|
||||
|
|
|
|||
Loading…
Reference in a new issue