1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use kcode_k1_audio_classification::{
5 AudioClassification, FragmentId, FragmentStatus, OverallState,
6};
7use kcode_k1_full_audio_domain::{
8 ManifestEntry, decode_manifest, encode_manifest, stitch_transcripts, validate_fragment_geometry,
9};
10use kcode_k1_objects::{K1Objects, Object};
11
12const MANIFEST_FILE_TYPE: &str = "k1-full-audio-manifest-v1";
13const FRAGMENT_FILE_TYPE: &str = "audio/ogg";
14
15pub type FullAudioId = kcode_k1_objects::TxId;
16
17pub struct K1FullAudio {
18 ffmpeg_path: PathBuf,
19 objects: Arc<K1Objects>,
20 classification: Arc<AudioClassification>,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub enum FullAudioState {
25 Processing,
26 AwaitingLabels,
27 NeedsAttention,
28 Complete,
29}
30
31#[derive(Clone, Debug, PartialEq)]
32pub struct FullAudioFragmentStatus {
33 pub fragment_id: FragmentId,
34 pub start_sample_48k: u64,
35 pub end_sample_48k: u64,
36 pub status: FragmentStatus,
37}
38
39#[derive(Clone, Debug, PartialEq)]
40pub struct FullAudioStatus {
41 pub state: FullAudioState,
42 pub fragments: Vec<FullAudioFragmentStatus>,
43 pub final_transcript: Option<String>,
44}
45
46struct PendingFragment {
47 start_sample_48k: u64,
48 end_sample_48k: u64,
49 ogg_bytes: Vec<u8>,
50}
51
52impl K1FullAudio {
53 pub fn open(
54 ffmpeg_path: impl AsRef<Path>,
55 objects: Arc<K1Objects>,
56 classification: Arc<AudioClassification>,
57 ) -> Result<Self, String> {
58 Ok(Self {
59 ffmpeg_path: validate_ffmpeg_path(ffmpeg_path.as_ref())?,
60 objects,
61 classification,
62 })
63 }
64
65 pub fn submit(&self, audio: &[u8]) -> Result<FullAudioId, String> {
66 submit_orchestration(
67 audio,
68 |input| kcode_audio_to_ogg_opus::convert_to_ogg_opus(&self.ffmpeg_path, input),
69 |ogg_bytes| {
70 kcode_ogg_opus_fragments::split_ogg_opus(ogg_bytes).map(|fragments| {
71 fragments
72 .into_iter()
73 .map(|fragment| PendingFragment {
74 start_sample_48k: fragment.start_sample_48k,
75 end_sample_48k: fragment.end_sample_48k,
76 ogg_bytes: fragment.ogg_bytes,
77 })
78 .collect()
79 })
80 },
81 |ogg_bytes| self.classification.submit(ogg_bytes),
82 |manifest| self.objects.save("", MANIFEST_FILE_TYPE, "", manifest),
83 )
84 }
85
86 pub fn status(&self, id: FullAudioId) -> Result<FullAudioStatus, String> {
87 status_orchestration(
88 id,
89 |object_id| self.objects.load(object_id),
90 |fragment_id| self.classification.status(fragment_id),
91 )
92 }
93
94 pub fn fragment_audio(
95 &self,
96 id: FullAudioId,
97 fragment_id: FragmentId,
98 ) -> Result<Vec<u8>, String> {
99 fragment_audio_orchestration(id, fragment_id, |object_id| self.objects.load(object_id))
100 }
101}
102
103fn validate_ffmpeg_path(path: &Path) -> Result<PathBuf, String> {
104 if !path.is_absolute() {
105 return Err("ffmpeg path must be absolute".to_owned());
106 }
107 Ok(path.to_path_buf())
108}
109
110fn submit_orchestration<Convert, Split, Submit, Save>(
111 audio: &[u8],
112 convert: Convert,
113 split: Split,
114 mut submit: Submit,
115 save: Save,
116) -> Result<FullAudioId, String>
117where
118 Convert: FnOnce(&[u8]) -> Result<Vec<u8>, String>,
119 Split: FnOnce(&[u8]) -> Result<Vec<PendingFragment>, String>,
120 Submit: FnMut(&[u8]) -> Result<FragmentId, String>,
121 Save: FnOnce(&[u8]) -> Result<FullAudioId, String>,
122{
123 let ogg_bytes = convert(audio)?;
124 let fragments = split(&ogg_bytes)?;
125 validate_fragment_geometry(
126 fragments
127 .iter()
128 .map(|fragment| (fragment.start_sample_48k, fragment.end_sample_48k)),
129 )?;
130 let mut entries = Vec::new();
131 entries
132 .try_reserve_exact(fragments.len())
133 .map_err(|error| format!("allocate manifest entries: {error}"))?;
134 for fragment in fragments {
135 let fragment_id = submit(&fragment.ogg_bytes)?;
136 entries.push(ManifestEntry {
137 fragment_id,
138 start_sample_48k: fragment.start_sample_48k,
139 end_sample_48k: fragment.end_sample_48k,
140 });
141 }
142 let manifest = encode_manifest(&entries)?;
143 save(&manifest)
144}
145
146fn load_manifest_entries<Load>(
147 id: FullAudioId,
148 load: &mut Load,
149) -> Result<Vec<ManifestEntry>, String>
150where
151 Load: FnMut(FullAudioId) -> Result<Option<Object>, String>,
152{
153 let object = load(id)?.ok_or_else(|| "unknown full-audio ID".to_owned())?;
154 if object.file_type != MANIFEST_FILE_TYPE
155 || !object.filename.is_empty()
156 || !object.description.is_empty()
157 {
158 return Err("object is not a full-audio manifest".to_owned());
159 }
160 decode_manifest(&object.data)
161}
162
163fn status_orchestration<Load, Status>(
164 id: FullAudioId,
165 mut load: Load,
166 mut status: Status,
167) -> Result<FullAudioStatus, String>
168where
169 Load: FnMut(FullAudioId) -> Result<Option<Object>, String>,
170 Status: FnMut(FragmentId) -> Result<Option<FragmentStatus>, String>,
171{
172 let entries = load_manifest_entries(id, &mut load)?;
173 let mut fragments = Vec::new();
174 fragments
175 .try_reserve_exact(entries.len())
176 .map_err(|error| format!("allocate fragment statuses: {error}"))?;
177 for entry in entries {
178 let fragment_status = status(entry.fragment_id)?
179 .ok_or_else(|| format!("unknown audio fragment {}", entry.fragment_id))?;
180 fragments.push(FullAudioFragmentStatus {
181 fragment_id: entry.fragment_id,
182 start_sample_48k: entry.start_sample_48k,
183 end_sample_48k: entry.end_sample_48k,
184 status: fragment_status,
185 });
186 }
187 let state = derive_state(&fragments)?;
188 let final_transcript = if state == FullAudioState::Complete {
189 let transcripts = fragments
190 .iter()
191 .filter_map(|fragment| fragment.status.final_transcript.as_deref())
192 .collect::<Vec<_>>();
193 Some(stitch_transcripts(&transcripts))
194 } else {
195 None
196 };
197 Ok(FullAudioStatus {
198 state,
199 fragments,
200 final_transcript,
201 })
202}
203
204fn fragment_audio_orchestration<Load>(
205 id: FullAudioId,
206 fragment_id: FragmentId,
207 mut load: Load,
208) -> Result<Vec<u8>, String>
209where
210 Load: FnMut(FullAudioId) -> Result<Option<Object>, String>,
211{
212 let entries = load_manifest_entries(id, &mut load)?;
213 if !entries.iter().any(|entry| entry.fragment_id == fragment_id) {
214 return Err("fragment does not belong to full audio".to_owned());
215 }
216 let object =
217 load(fragment_id)?.ok_or_else(|| format!("unknown audio fragment {fragment_id}"))?;
218 if object.file_type != FRAGMENT_FILE_TYPE
219 || !object.filename.is_empty()
220 || !object.description.is_empty()
221 {
222 return Err("object is not an audio fragment".to_owned());
223 }
224 Ok(object.data)
225}
226
227fn derive_state(fragments: &[FullAudioFragmentStatus]) -> Result<FullAudioState, String> {
228 if fragments.is_empty() {
229 return Err("manifest has no fragments".to_owned());
230 }
231 for fragment in fragments {
232 let confirmed = fragment.status.state == OverallState::Confirmed;
233 let has_transcript = fragment.status.final_transcript.is_some();
234 if confirmed && !has_transcript {
235 return Err("confirmed fragment has no final transcript".to_owned());
236 }
237 if !confirmed && has_transcript {
238 return Err("non-confirmed fragment has a final transcript".to_owned());
239 }
240 }
241 if has_state(fragments, &[OverallState::Failed, OverallState::Discarded]) {
242 return Ok(FullAudioState::NeedsAttention);
243 }
244 if has_state(fragments, &[OverallState::Queued, OverallState::Running]) {
245 return Ok(FullAudioState::Processing);
246 }
247 if fragments
248 .iter()
249 .all(|fragment| fragment.status.state == OverallState::Confirmed)
250 {
251 return Ok(FullAudioState::Complete);
252 }
253 if fragments.iter().all(|fragment| {
254 [OverallState::Completed, OverallState::Confirmed].contains(&fragment.status.state)
255 }) {
256 return Ok(FullAudioState::AwaitingLabels);
257 }
258 Err("classification returned an inconsistent state".to_owned())
259}
260
261fn has_state(fragments: &[FullAudioFragmentStatus], states: &[OverallState]) -> bool {
262 fragments
263 .iter()
264 .any(|fragment| states.contains(&fragment.status.state))
265}
266
267#[cfg(test)]
268mod tests;