Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/lib/backend/schema/person.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class Person {
final DateTime createdAt;
final DateTime updatedAt;
final List<String>? speechSamples;
final List<String>? speechSampleTranscripts;
final int speechSamplesVersion;
final int? colorIdx;

Person({
Expand All @@ -39,6 +41,8 @@ class Person {
required this.createdAt,
required this.updatedAt,
this.speechSamples,
this.speechSampleTranscripts,
this.speechSamplesVersion = 1,
this.colorIdx,
});

Expand All @@ -49,6 +53,10 @@ class Person {
createdAt: DateTime.parse(json['created_at']).toLocal(),
updatedAt: DateTime.parse(json['updated_at']).toLocal(),
speechSamples: json['speech_samples'] != null ? List<String>.from(json['speech_samples']) : [],
speechSampleTranscripts: json['speech_sample_transcripts'] != null
? List<String>.from(json['speech_sample_transcripts'])
: null,
speechSamplesVersion: json['speech_samples_version'] ?? 1,
colorIdx: json['color_idx'] ?? json['id'].hashCode % speakerColors.length,
);
}
Expand All @@ -60,6 +68,8 @@ class Person {
'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toUtc().toIso8601String(),
'speech_samples': speechSamples ?? [],
'speech_sample_transcripts': speechSampleTranscripts,
'speech_samples_version': speechSamplesVersion,
'color_idx': colorIdx,
};
}
Expand Down
23 changes: 22 additions & 1 deletion app/lib/pages/settings/people.dart
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,28 @@ class _UserPeoplePageState extends State<_UserPeoplePage> {
title:
Text(j == 0 ? context.l10n.speechProfile : context.l10n.sampleNumber(j)),
onTap: () => _confirmDeleteSample(index, person, j, provider),
subtitle: Text('Tap to delete'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (person.speechSampleTranscripts != null &&
j < person.speechSampleTranscripts!.length &&
person.speechSampleTranscripts![j].isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'"${person.speechSampleTranscripts![j]}"',
style: const TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
),
),
),
const Text(
'Tap to delete',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
)),
],
),
Expand Down
223 changes: 200 additions & 23 deletions backend/database/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,40 +100,58 @@ def delete_person(uid: str, person_id: str):
person_ref.delete()


def add_person_speech_sample(uid: str, person_id: str, sample_path: str, max_samples: int = 5) -> bool:
@transactional
def _add_sample_transaction(transaction, person_ref, sample_path, transcript, max_samples):
"""Transaction to atomically add sample and transcript."""
snapshot = person_ref.get(transaction=transaction)
if not snapshot.exists:
return False

person_data = snapshot.to_dict()
samples = person_data.get('speech_samples', [])

if len(samples) >= max_samples:
return False

samples.append(sample_path)
update_data = {
'speech_samples': samples,
'updated_at': datetime.now(timezone.utc),
}

if transcript is not None:
transcripts = person_data.get('speech_sample_transcripts', [])
transcripts.append(transcript)
update_data['speech_sample_transcripts'] = transcripts
update_data['speech_samples_version'] = 2

transaction.update(person_ref, update_data)
return True


def add_person_speech_sample(
uid: str, person_id: str, sample_path: str, transcript: Optional[str] = None, max_samples: int = 5
) -> bool:
"""
Append speech sample path to person's speech_samples list.
Limits to max_samples to prevent unlimited growth.

Uses Firestore transaction to ensure atomic read-modify-write,
preventing array drift from concurrent updates.

Args:
uid: User ID
person_id: Person ID
sample_path: GCS path to the speech sample
transcript: Optional transcript text for the sample
max_samples: Maximum number of samples to keep (default 5)

Returns:
True if sample was added, False if limit reached
True if sample was added, False if limit reached or person not found
"""
person_ref = db.collection('users').document(uid).collection('people').document(person_id)
person_doc = person_ref.get()

if not person_doc.exists:
return False

person_data = person_doc.to_dict()
current_samples = person_data.get('speech_samples', [])

# Check if we've hit the limit
if len(current_samples) >= max_samples:
return False

person_ref.update(
{
'speech_samples': firestore.ArrayUnion([sample_path]),
'updated_at': datetime.now(timezone.utc),
}
)
return True
transaction = db.transaction()
return _add_sample_transaction(transaction, person_ref, sample_path, transcript, max_samples)


def get_person_speech_samples_count(uid: str, person_id: str) -> int:
Expand All @@ -151,24 +169,41 @@ def get_person_speech_samples_count(uid: str, person_id: str) -> int:
def remove_person_speech_sample(uid: str, person_id: str, sample_path: str) -> bool:
"""
Remove a speech sample path from person's speech_samples list.
Also removes the corresponding transcript at the same index to keep arrays in sync.

Args:
uid: User ID
person_id: Person ID
sample_path: GCS path to remove

Returns:
True if removed, False if person not found
True if removed, False if person or sample not found
"""
person_ref = db.collection('users').document(uid).collection('people').document(person_id)
person_doc = person_ref.get()

if not person_doc.exists:
return False

person_data = person_doc.to_dict()
samples = person_data.get('speech_samples', [])
transcripts = person_data.get('speech_sample_transcripts', [])

# Find index of sample to remove
try:
idx = samples.index(sample_path)
except ValueError:
return False # Sample not found

# Remove from both arrays by index
samples.pop(idx)
if idx < len(transcripts):
transcripts.pop(idx)

person_ref.update(
{
'speech_samples': firestore.ArrayRemove([sample_path]),
'speech_samples': samples,
'speech_sample_transcripts': transcripts,
'updated_at': datetime.now(timezone.utc),
}
)
Expand Down Expand Up @@ -223,6 +258,148 @@ def get_person_speaker_embedding(uid: str, person_id: str) -> Optional[list]:
return person_data.get('speaker_embedding')


def set_person_speech_sample_transcript(uid: str, person_id: str, sample_index: int, transcript: str) -> bool:
"""
Update transcript at a specific index in the speech_sample_transcripts array.

Args:
uid: User ID
person_id: Person ID
sample_index: Index of the sample/transcript to update
transcript: The transcript text to set

Returns:
True if updated successfully, False if person not found or index out of bounds
"""
person_ref = db.collection('users').document(uid).collection('people').document(person_id)
person_doc = person_ref.get()

if not person_doc.exists:
return False

person_data = person_doc.to_dict()
samples = person_data.get('speech_samples', [])
transcripts = person_data.get('speech_sample_transcripts', [])

# Validate index
if sample_index < 0 or sample_index >= len(samples):
return False

# Extend transcripts array if needed
while len(transcripts) < len(samples):
transcripts.append('')

transcripts[sample_index] = transcript

person_ref.update(
{
'speech_sample_transcripts': transcripts,
'updated_at': datetime.now(timezone.utc),
}
)
return True


def update_person_speech_samples_after_migration(
uid: str,
person_id: str,
samples: list,
transcripts: list,
version: int,
speaker_embedding: Optional[list] = None,
) -> bool:
"""
Replace all samples/transcripts/embedding and set version atomically.
Used after v1 to v2 migration to update all related fields together.

Args:
uid: User ID
person_id: Person ID
samples: List of sample paths (may have dropped invalid samples)
transcripts: List of transcript strings (parallel array with samples)
version: Version number to set (typically 2)
speaker_embedding: Optional new speaker embedding, or None to clear

Returns:
True if updated successfully, False if person not found
"""
person_ref = db.collection('users').document(uid).collection('people').document(person_id)
person_doc = person_ref.get()

if not person_doc.exists:
return False

update_data = {
'speech_samples': samples,
'speech_sample_transcripts': transcripts,
'speech_samples_version': version,
'updated_at': datetime.now(timezone.utc),
}

# Set or clear speaker embedding
if speaker_embedding is not None:
update_data['speaker_embedding'] = speaker_embedding
else:
update_data['speaker_embedding'] = firestore.DELETE_FIELD

person_ref.update(update_data)
return True


def clear_person_speaker_embedding(uid: str, person_id: str) -> bool:
"""
Clear speaker embedding for a person.
Used when all samples are dropped during migration.

Args:
uid: User ID
person_id: Person ID

Returns:
True if cleared successfully, False if person not found
"""
person_ref = db.collection('users').document(uid).collection('people').document(person_id)
person_doc = person_ref.get()

if not person_doc.exists:
return False

person_ref.update(
{
'speaker_embedding': firestore.DELETE_FIELD,
'updated_at': datetime.now(timezone.utc),
}
)
return True


def update_person_speech_samples_version(uid: str, person_id: str, version: int) -> bool:
"""
Update just the speech_samples_version field.

Args:
uid: User ID
person_id: Person ID
version: Version number to set

Returns:
True if updated successfully, False if person not found
"""
person_ref = db.collection('users').document(uid).collection('people').document(person_id)
person_doc = person_ref.get()

if not person_doc.exists:
return False

person_ref.update(
{
'speech_samples_version': version,
'updated_at': datetime.now(timezone.utc),
}
)
return True


def delete_user_data(uid: str):
user_ref = db.collection('users').document(uid)
if not user_ref.get().exists:
Expand Down
4 changes: 3 additions & 1 deletion backend/models/other.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import List
from typing import List, Optional

from pydantic import BaseModel, Field

Expand All @@ -24,3 +24,5 @@ class Person(BaseModel):
created_at: datetime
updated_at: datetime
speech_samples: List[str] = []
speech_sample_transcripts: Optional[List[str]] = None
speech_samples_version: int = 1
6 changes: 3 additions & 3 deletions backend/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,10 @@ def get_all_people(include_speech_samples: bool = True, uid: str = Depends(auth.
print('get_all_people', include_speech_samples)
people = get_people(uid)
if include_speech_samples:
# Convert stored GCS paths to signed URLs for each person
for person in people:
# Convert GCS paths to signed URLs for each person
for i, person in enumerate(people):
stored_paths = person.get('speech_samples', [])
person['speech_samples'] = get_speech_sample_signed_urls(stored_paths)
people[i]['speech_samples'] = get_speech_sample_signed_urls(stored_paths)
return people


Expand Down
Loading