Skip to content

Commit b355ee1

Browse files
authored
Shared segment validator (#661)
* Shared segment validator * add new audio annotator role
1 parent 82a0d55 commit b355ee1

11 files changed

Lines changed: 1167 additions & 283 deletions

File tree

app/controllers/application_controller.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ def can_manage?(resource)
5555
if resource
5656
@access = if current_user.is_super_admin?
5757
AdminProjectAccess.new
58+
elsif current_user.is_audio_annotator? && resource.recitation?
59+
AdminProjectAccess.new
5860
else
5961
access = current_user.user_projects.find_by(resource_content_id: resource.id)
6062
access if access&.approved?

app/controllers/surah_audio_files_controller.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ class SurahAudioFilesController < CommunityController
22
before_action :authenticate_user!, only: [:save_segments]
33
before_action :authorize_access!, only: [:save_segments]
44
before_action :init_presenter
5+
56
def builder_help
67
render layout: false
78
end
@@ -41,6 +42,20 @@ def segments
4142
.order('verses.id ASC')
4243
end
4344

45+
def validate_segments
46+
@audio_file = load_audio_file
47+
return render(json: { issues: [] }) if @audio_file.nil?
48+
49+
issues =
50+
if params[:segments].present?
51+
validate_posted_segments(@audio_file, params[:segments])
52+
else
53+
@recitation.validate_segments_data(audio_file: @audio_file)
54+
end
55+
56+
render json: { issues: issues.map { |issue| serialize_issue(issue) } }
57+
end
58+
4459
def save_segments
4560
audio_file = load_audio_file
4661
key = params[:verse_key].to_s.strip
@@ -79,6 +94,53 @@ def save_segments
7994
end
8095

8196
protected
97+
98+
def validate_posted_segments(audio_file, posted)
99+
chapter = audio_file.chapter
100+
verses_by_number = Verse.where(chapter_id: chapter.id).index_by(&:verse_number)
101+
102+
segments = posted.to_unsafe_h.map do |verse_key, data|
103+
verse_number = verse_key.to_s.split(':').last.to_i
104+
verse = verses_by_number[verse_number]
105+
106+
Audio::SegmentValidator::SegmentData.new(
107+
verse_key: verse_key.to_s,
108+
chapter_id: chapter.id,
109+
verse_number: verse_number,
110+
timestamp_from: cast_ms(data['timestamp_from']),
111+
timestamp_to: cast_ms(data['timestamp_to']),
112+
words_count: verse&.words_count.to_i,
113+
word_segments: cast_word_segments(data['segments']),
114+
audio_file_id: audio_file.id,
115+
audio_duration_ms: audio_file.duration_ms
116+
)
117+
end
118+
119+
Audio::SegmentValidator.new(segments, expected_verses_count: chapter.verses_count).validate
120+
end
121+
122+
def serialize_issue(issue)
123+
verse_number = issue[:key] ? issue[:key].to_s.split(':').last.to_i : nil
124+
issue.merge(verse: verse_number)
125+
end
126+
127+
def cast_ms(value)
128+
return nil if value.nil? || value == ''
129+
130+
Integer(value)
131+
rescue ArgumentError, TypeError
132+
value.to_i
133+
end
134+
135+
def cast_word_segments(segments)
136+
return [] if segments.blank?
137+
138+
Array(segments).map do |word_segment|
139+
word_segment = word_segment.values if word_segment.respond_to?(:values)
140+
[word_segment[0], cast_ms(word_segment[1]), cast_ms(word_segment[2])]
141+
end
142+
end
143+
82144
def sort_key
83145
sort_by = params[:sort_key].presence || 'chapter_id'
84146

app/javascript/segments/components/Verse.vue

Lines changed: 108 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@
5555
</div>
5656

5757
<div class="max-h-[60vh] overflow-y-auto divide-y divide-gray-100">
58-
<p v-if="!activeIssues.length" class="px-4 py-8 text-sm text-gray-500 text-center">
58+
<p v-if="issuesLoading" class="px-4 py-8 text-sm text-gray-500 text-center">
59+
Checking segments…
60+
</p>
61+
<p v-else-if="issuesError" class="px-4 py-8 text-sm text-red-600 text-center">
62+
Could not validate segments. Please try again.
63+
</p>
64+
<p v-else-if="!activeIssues.length" class="px-4 py-8 text-sm text-gray-500 text-center">
5965
No issues found 🎉
6066
</p>
6167
<button
@@ -533,6 +539,8 @@ export default {
533539
return {
534540
timeStep: 50,
535541
showIssues: false,
542+
issuesLoading: false,
543+
issuesError: false,
536544
issueGroups: [],
537545
activeIssueTab: 'current',
538546
showCompare: false,
@@ -853,23 +861,108 @@ export default {
853861
redo() {
854862
this.$store.commit('REDO_SEGMENTS');
855863
},
856-
openIssues() {
857-
const groups = [
858-
{ id: 'current', name: 'Current', color: '#198754', issues: this.findSegmentIssues((verse) => this.mainVerseData(verse)) },
859-
];
864+
async openIssues() {
865+
this.activeIssueTab = 'current';
866+
this.showIssues = true;
860867
861-
for (const source of this.compareSources) {
862-
groups.push({
863-
id: source.id,
864-
name: source.name,
865-
color: source.color,
866-
issues: this.findSegmentIssues((verse) => this.sourceVerseData(source, verse)),
867-
});
868+
// The gapped (ayah-by-ayah) tool keeps client-side validation across every
869+
// compare source. The gapless (surah) tool is unified: its authoritative
870+
// issues come from the server so it runs the exact same rules as the admin
871+
// model view and the export job.
872+
if (this.audioType === 'ayah') {
873+
const groups = [
874+
{ id: 'current', name: 'Current', color: '#198754', issues: this.findSegmentIssues((verse) => this.mainVerseData(verse)) },
875+
];
876+
877+
for (const source of this.compareSources) {
878+
groups.push({
879+
id: source.id,
880+
name: source.name,
881+
color: source.color,
882+
issues: this.findSegmentIssues((verse) => this.sourceVerseData(source, verse)),
883+
});
884+
}
885+
886+
this.issueGroups = groups;
887+
return;
868888
}
869889
870-
this.issueGroups = groups;
871-
this.activeIssueTab = 'current';
872-
this.showIssues = true;
890+
this.issuesError = false;
891+
this.issuesLoading = true;
892+
this.issueGroups = [{ id: 'current', name: 'Current', color: '#198754', issues: [] }];
893+
894+
let issues = [];
895+
try {
896+
// Validate the live in-browser state, so issues the reviewer already
897+
// fixed (but has not saved) are not reported. Nothing is persisted.
898+
issues = await this.fetchServerIssues();
899+
} catch (error) {
900+
this.issuesError = true;
901+
}
902+
903+
// The one check the server cannot do: the audio can continue past the last
904+
// ayah using the real decoded duration (the server only knows the stored
905+
// file.duration_ms).
906+
const durationIssue = this.fileDurationIssue();
907+
if (durationIssue) issues.push(durationIssue);
908+
909+
issues.sort((a, b) => (a.severity === 'major' ? 0 : 1) - (b.severity === 'major' ? 0 : 1));
910+
911+
this.issueGroups = [{ id: 'current', name: 'Current', color: '#198754', issues }];
912+
this.issuesLoading = false;
913+
},
914+
async fetchServerIssues() {
915+
const recitation = this.$store.state.recitation;
916+
const segmentsUrl = this.$store.state.segmentsUrl;
917+
918+
const csrfTokenElement = document.querySelector('meta[name="csrf-token"]');
919+
const headers = { 'Content-Type': 'application/json' };
920+
if (csrfTokenElement) headers['X-CSRF-Token'] = csrfTokenElement.content;
921+
922+
const response = await fetch(`/${segmentsUrl}/${recitation}/validate_segments.json`, {
923+
method: 'post',
924+
headers,
925+
body: JSON.stringify({ chapter_id: this.chapter, segments: this.segments }),
926+
});
927+
928+
if (!response.ok) throw new Error(`Validation request failed (${response.status})`);
929+
930+
const json = await response.json();
931+
932+
return (json.issues || []).map((issue, index) => ({
933+
verse: issue.verse || (issue.key ? Number(String(issue.key).split(':').pop()) : null),
934+
key: `server-${index}`,
935+
severity: issue.severity === 'bg-danger' ? 'major' : 'minor',
936+
message: issue.text,
937+
}));
938+
},
939+
fileDurationIssue() {
940+
const TRAILING_GAP_THRESHOLD_MS = 1000;
941+
const audioDuration = this.playerDurationMs();
942+
if (!audioDuration) return null;
943+
944+
const present = (value) => value !== undefined && value !== null && value !== '';
945+
946+
let lastVerse = null;
947+
let lastEnd = null;
948+
for (let verse = 1; verse <= this.versesCount; verse++) {
949+
const data = this.segments[`${this.chapter}:${verse}`];
950+
if (data && present(data.timestamp_to)) {
951+
lastVerse = verse;
952+
lastEnd = Number(data.timestamp_to);
953+
}
954+
}
955+
if (lastEnd === null) return null;
956+
957+
const gap = audioDuration - lastEnd;
958+
if (gap <= TRAILING_GAP_THRESHOLD_MS) return null;
959+
960+
return {
961+
verse: lastVerse,
962+
key: 'client-file-duration',
963+
severity: 'major',
964+
message: `Audio continues ${Math.round(gap / 1000)}s past the last ayah ends (unsegmented tail)`,
965+
};
873966
},
874967
goToIssue(verse) {
875968
this.$store.commit('CHANGE_AYAH', { to: verse });

0 commit comments

Comments
 (0)