Skip to content
Open
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
2 changes: 1 addition & 1 deletion app/src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@
"editTranscription": "Edit transcription",
"deleteSample": "Delete sample",
"addSample": "Add Sample",
"note": "Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple samples. In a future update samples might be interchangeable and tagged for varying styles of the same voice.",
"note": "Add multiple samples to improve voice quality — more diverse recordings produce better results. Samples are automatically combined.",
"deleteDialog": {
"title": "Delete Sample",
"description": "Are you sure you want to delete this audio sample? This action cannot be undone.",
Expand Down
2 changes: 1 addition & 1 deletion app/src/i18n/locales/ja/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@
"editTranscription": "文字起こしを編集",
"deleteSample": "サンプルを削除",
"addSample": "サンプルを追加",
"note": "メモ:30 秒のサンプル 1 本が最適です。サンプルを複数追加すると品質が低下することがあります。今後のアップデートで、同じボイスの異なるスタイル向けにサンプルを切り替え可能にし、タグ付けできるようにするかもしれません。",
"note": "複数のサンプルを追加すると音声品質が向上します — 多様な録音ほど良い結果が得られます。サンプルは自動的に結合されます。",
"deleteDialog": {
"title": "サンプルを削除",
"description": "このオーディオサンプルを本当に削除しますか? この操作は元に戻せません。",
Expand Down
2 changes: 1 addition & 1 deletion app/src/i18n/locales/zh-CN/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@
"editTranscription": "编辑转录",
"deleteSample": "删除样本",
"addSample": "添加样本",
"note": "注意:单个 30 秒的样本效果最佳。多个样本可能会降低质量。未来版本中样本可能会变得可互换,并为同一声音的不同风格打标签。",
"note": "添加多个样本可提升音质 — 录音越多样化,效果越好。样本将自动合并。",
"deleteDialog": {
"title": "删除样本",
"description": "确定要删除此音频样本吗?此操作不可撤销。",
Expand Down
2 changes: 1 addition & 1 deletion app/src/i18n/locales/zh-TW/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@
"editTranscription": "編輯轉錄",
"deleteSample": "刪除樣本",
"addSample": "新增樣本",
"note": "注意:單一 30 秒的樣本效果最佳。多個樣本可能會降低品質。未來版本中樣本可能可互換,並為同一聲音的不同風格加上標籤。",
"note": "添加多個樣本可提升音質 — 錄音越多樣化,效果越好。樣本將自動合併。",
"deleteDialog": {
"title": "刪除樣本",
"description": "確定要刪除此音訊樣本嗎?此操作無法復原。",
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/api/models/ProfileSampleResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export type ProfileSampleResponse = {
profile_id: string;
audio_path: string;
reference_text: string;
sort_order: number;
};
4 changes: 4 additions & 0 deletions app/src/lib/api/schemas/$ProfileSampleResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@ export const $ProfileSampleResponse = {
type: 'string',
isRequired: true,
},
sort_order: {
type: 'number',
isRequired: true,
},
},
} as const;
46 changes: 40 additions & 6 deletions app/src/lib/hooks/useAudioRecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ export function useAudioRecording({
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const cancelledRef = useRef<boolean>(false);
// Monotonically-increasing session counter. Each call to startRecording
// increments it; the onstop closure captures it and bails out if it no
// longer matches — prevents a slow convertToWav from a previous session
// from calling onRecordingComplete after a new recording has already begun.
const sessionRef = useRef<number>(0);

const startRecording = useCallback(async () => {
try {
setError(null);
chunksRef.current = [];
cancelledRef.current = false;
sessionRef.current += 1;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Capture session-local state before the async start path.

Because thisSession is only read on Line 101, after the awaited setup path, two overlapping startRecording() calls can both inherit the later session id. The older recorder still appends into the shared refs, so its final dataavailable can leak into the next recording and the stale-session guard won't reliably suppress it.

Suggested fix
-      sessionRef.current += 1;
+      const thisSession = sessionRef.current + 1;
+      sessionRef.current = thisSession;
+      const sessionChunks: Blob[] = [];
+      let recordingStartedAt: number | null = null;
...
       const stream = await navigator.mediaDevices.getUserMedia({
         audio: {
           echoCancellation: true,
           noiseSuppression: true,
           autoGainControl: true,
         },
       });
+
+      if (sessionRef.current !== thisSession) {
+        stream.getTracks().forEach((track) => track.stop());
+        return;
+      }

       streamRef.current = stream;
...
       mediaRecorder.ondataavailable = (event) => {
         if (event.data.size > 0) {
-          chunksRef.current.push(event.data);
+          sessionChunks.push(event.data);
         }
       };
-
-      const thisSession = sessionRef.current;
...
         const recordedDuration = startTimeRef.current
-          ? (Date.now() - startTimeRef.current) / 1000
+          ? (Date.now() - recordingStartedAt!) / 1000
           : undefined;

-        const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
+        const webmBlob = new Blob(sessionChunks, { type: 'audio/webm' });
...
-      startTimeRef.current = Date.now();
+      recordingStartedAt = Date.now();
+      startTimeRef.current = recordingStartedAt;

Also applies to: 97-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/lib/hooks/useAudioRecording.ts` at line 35, The session id
(sessionRef.current) must be captured into a session-local variable before any
awaited/async setup so concurrent startRecording calls don't share the later
session id; inside startRecording() increment sessionRef.current, then
immediately assign const thisSession = sessionRef.current and use thisSession in
the dataavailable event handler and any later session checks (instead of reading
sessionRef.current after await). Apply the same pattern where the code currently
reads sessionRef.current around the async path (the block referenced by lines
~97-101) so each recorder closure validates against its own captured thisSession
to avoid leaking old dataavailable events into new recordings.

setDuration(0);

// Check if getUserMedia is available
Expand Down Expand Up @@ -88,6 +94,12 @@ export function useAudioRecording({
}
};

// Capture the session ID for this recording at the time the recorder
// is set up. If the user starts a new recording before the async
// onstop work finishes (e.g. convertToWav is slow), the new session
// will have a different ID and we skip the stale completion callback.
const thisSession = sessionRef.current;

mediaRecorder.onstop = async () => {
// Snapshot the cancellation flag and recorded duration immediately —
// cancelRecording() clears chunks and sets cancelledRef synchronously
Expand All @@ -105,17 +117,32 @@ export function useAudioRecording({
});
streamRef.current = null;

// Clear the recorder ref now that it's done — prevents stopRecording
// from accidentally operating on an already-stopped MediaRecorder if
// the user clicks stop again before state catches up.
if (mediaRecorderRef.current === mediaRecorder) {
mediaRecorderRef.current = null;
}

// Don't fire completion callback if the recording was cancelled
if (wasCancelled) return;

// Don't fire completion callback if a newer session has started
if (sessionRef.current !== thisSession) return;

// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
onRecordingComplete?.(wavBlob, recordedDuration);
// Final guard: still belongs to this session?
if (sessionRef.current === thisSession) {
onRecordingComplete?.(wavBlob, recordedDuration);
}
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
onRecordingComplete?.(webmBlob, recordedDuration);
if (sessionRef.current === thisSession) {
onRecordingComplete?.(webmBlob, recordedDuration);
}
}
};

Expand Down Expand Up @@ -162,10 +189,12 @@ export function useAudioRecording({
setError(errorMessage);
setIsRecording(false);
}
}, [maxDurationSeconds, onRecordingComplete]);
}, [maxDurationSeconds, onRecordingComplete, platform.metadata.isTauri]);

const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && isRecording) {
// Check the ref rather than the `isRecording` state so this works even
// if React hasn't flushed the state update yet (e.g. rapid UI clicks).
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
setIsRecording(false);

Expand All @@ -174,13 +203,18 @@ export function useAudioRecording({
timerRef.current = null;
}
}
}, [isRecording]);
}, []);

const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current) {
cancelledRef.current = true; // Must be set before stop() triggers onstop
chunksRef.current = [];
mediaRecorderRef.current.stop();
if (mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
// Clear immediately so onstop (if it fires synchronously on some
// browsers) doesn't see a stale ref.
mediaRecorderRef.current = null;
setIsRecording(false);
setDuration(0);
}
Expand Down
76 changes: 62 additions & 14 deletions backend/database/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ def run_migrations(engine) -> None:
_migrate_generation_versions(engine, inspector, tables)
_migrate_capture_settings(engine, inspector, tables)
_migrate_mcp_bindings(engine, inspector, tables)
_migrate_profile_samples(engine, inspector, tables)
_normalize_storage_paths(engine, tables)
_add_performance_indexes(engine, tables)


# -- helpers ---------------------------------------------------------------


def _get_columns(inspector, table: str) -> set[str]:
return {col["name"] for col in inspector.get_columns(table)}

Expand All @@ -62,6 +65,7 @@ def _add_column(engine, table: str, column_sql: str, label: str) -> None:

# -- per-table migrations --------------------------------------------------


def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
if "story_items" not in tables:
return
Expand All @@ -73,15 +77,15 @@ def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
logger.info("Migrating story_items: removing position column, using start_time_ms")
with engine.connect() as conn:
if "start_time_ms" not in columns:
conn.execute(text(
"ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"
))
result = conn.execute(text("""
conn.execute(text("ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"))
result = conn.execute(
text("""
SELECT si.id, si.story_id, si.position, g.duration
FROM story_items si
JOIN generations g ON si.generation_id = g.id
ORDER BY si.story_id, si.position
"""))
""")
)
current_story_id = None
current_time_ms = 0
for item_id, story_id, _position, duration in result.fetchall():
Expand All @@ -96,7 +100,8 @@ def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
conn.commit()

# Recreate table without the position column (SQLite lacks DROP COLUMN)
conn.execute(text("""
conn.execute(
text("""
CREATE TABLE story_items_new (
id VARCHAR PRIMARY KEY,
story_id VARCHAR NOT NULL,
Expand All @@ -110,13 +115,16 @@ def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
FOREIGN KEY (story_id) REFERENCES stories(id),
FOREIGN KEY (generation_id) REFERENCES generations(id)
)
"""))
conn.execute(text("""
""")
)
conn.execute(
text("""
INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, track, trim_start_ms, trim_end_ms, version_id, created_at)
SELECT id, story_id, generation_id, start_time_ms,
COALESCE(track, 0), COALESCE(trim_start_ms, 0), COALESCE(trim_end_ms, 0), version_id, created_at
FROM story_items
"""))
""")
)
conn.execute(text("DROP TABLE story_items"))
conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items"))
conn.commit()
Expand Down Expand Up @@ -292,13 +300,55 @@ def _supports_drop_column(engine) -> bool:
return tuple(int(p) for p in sqlite3.sqlite_version.split(".")[:3]) >= (3, 35, 0)


def _migrate_profile_samples(engine, inspector, tables: set[str]) -> None:
if "profile_samples" not in tables:
return
columns = _get_columns(inspector, "profile_samples")
if "sort_order" not in columns:
_add_column(engine, "profile_samples", "sort_order INTEGER NOT NULL DEFAULT 0", "sort_order")
with engine.connect() as conn:
conn.execute(
text("CREATE INDEX IF NOT EXISTS ix_profile_samples_sort_order ON profile_samples (sort_order)")
)
conn.commit()
Comment on lines +307 to +313
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Index creation gated on column absence — won't self-heal a partial migration.

_add_column commits in its own transaction (line 62), so if the column add succeeds but the subsequent CREATE INDEX fails (or the DB is later restored from a snapshot taken in between, or the index is dropped manually), the next startup will see sort_order already present, skip the whole block, and the ix_profile_samples_sort_order index will never be recreated. Note _add_performance_indexes also does not include this particular index, so there's no second-chance path.

Since CREATE INDEX IF NOT EXISTS is idempotent, lift it out of the if (or add it to the _add_performance_indexes list) so every startup ensures the index exists.

🛡️ Proposed fix
 def _migrate_profile_samples(engine, inspector, tables: set[str]) -> None:
     if "profile_samples" not in tables:
         return
     columns = _get_columns(inspector, "profile_samples")
     if "sort_order" not in columns:
         _add_column(engine, "profile_samples", "sort_order INTEGER NOT NULL DEFAULT 0", "sort_order")
-        with engine.connect() as conn:
-            conn.execute(
-                text("CREATE INDEX IF NOT EXISTS ix_profile_samples_sort_order ON profile_samples (sort_order)")
-            )
-            conn.commit()
+    with engine.connect() as conn:
+        conn.execute(
+            text("CREATE INDEX IF NOT EXISTS ix_profile_samples_sort_order ON profile_samples (sort_order)")
+        )
+        conn.commit()

Alternatively, append ("profile_samples", "ix_profile_samples_sort_order", "sort_order") to the indexes list in _add_performance_indexes and let that helper own it.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if "sort_order" not in columns:
_add_column(engine, "profile_samples", "sort_order INTEGER NOT NULL DEFAULT 0", "sort_order")
with engine.connect() as conn:
conn.execute(
text("CREATE INDEX IF NOT EXISTS ix_profile_samples_sort_order ON profile_samples (sort_order)")
)
conn.commit()
if "sort_order" not in columns:
_add_column(engine, "profile_samples", "sort_order INTEGER NOT NULL DEFAULT 0", "sort_order")
with engine.connect() as conn:
conn.execute(
text("CREATE INDEX IF NOT EXISTS ix_profile_samples_sort_order ON profile_samples (sort_order)")
)
conn.commit()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/database/migrations.py` around lines 307 - 313, The index creation
for ix_profile_samples_sort_order is currently inside the "if 'sort_order' not
in columns" block so a partially-applied migration (column added but index
missing) will never self-heal; either move the CREATE INDEX IF NOT EXISTS call
for profile_samples(sort_order) out of that conditional so it runs on every
startup after calling _add_column, or add ("profile_samples",
"ix_profile_samples_sort_order", "sort_order") to the indexes list handled by
_add_performance_indexes so the helper ensures the index exists independently of
the column-add conditional; keep the existing use of IF NOT EXISTS and reference
_add_column, _add_performance_indexes, profile_samples, sort_order, and
ix_profile_samples_sort_order when making the change.



def _add_performance_indexes(engine, tables: set[str]) -> None:
"""Add query-performance indexes that were missing from the initial schema.

Each CREATE INDEX is wrapped in IF NOT EXISTS so this is safe to call on
every startup against both new and existing databases.
"""
indexes = [
# History page: filter/sort by profile, status, created_at
("generations", "ix_generations_profile_id", "profile_id"),
("generations", "ix_generations_status", "status"),
("generations", "ix_generations_created_at", "created_at"),
("generations", "ix_generations_is_favorited", "is_favorited"),
# Version lookups per generation
("generation_versions", "ix_generation_versions_generation_id", "generation_id"),
# Story item lookups per story
("story_items", "ix_story_items_story_id", "story_id"),
# Capture list sorted by date
("captures", "ix_captures_created_at", "created_at"),
# Sample lookups per profile
("profile_samples", "ix_profile_samples_profile_id", "profile_id"),
]
with engine.connect() as conn:
for table, index_name, column in indexes:
if table not in tables:
continue
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table} ({column})"))
conn.commit()


def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path

from ..config import get_data_dir, to_storage_path, resolve_storage_path
from ..config import get_data_dir, resolve_storage_path, to_storage_path

data_dir = get_data_dir()
get_data_dir()

path_columns = [
("generations", "audio_path"),
Expand All @@ -312,9 +362,7 @@ def _normalize_storage_paths(engine, tables: set[str]) -> None:
for table, column in path_columns:
if table not in tables:
continue
rows = conn.execute(
text(f"SELECT id, {column} FROM {table} WHERE {column} IS NOT NULL")
).fetchall()
rows = conn.execute(text(f"SELECT id, {column} FROM {table} WHERE {column} IS NOT NULL")).fetchall()
for row_id, path_val in rows:
if not path_val:
continue
Expand Down
Loading