ass_editor/commands/
karaoke_commands.rs

1//! Karaoke management commands for ASS karaoke timing
2//!
3//! Provides commands for generating, splitting, adjusting, and applying
4//! ASS karaoke timing tags like \k, \kf, \ko, \kt with proper syllable
5//! detection and timing validation.
6
7use super::{CommandResult, EditorCommand};
8use crate::core::{EditorDocument, EditorError, Position, Range, Result};
9
10#[cfg(not(feature = "std"))]
11use alloc::{
12    format,
13    string::{String, ToString},
14    vec,
15    vec::Vec,
16};
17
18/// Generate karaoke timing tags for text
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct GenerateKaraokeCommand {
21    /// Range of text to add karaoke to
22    pub range: Range,
23    /// Default syllable duration in centiseconds
24    pub default_duration: u32,
25    /// Type of karaoke tag to use (\k, \kf, \ko, \kt)
26    pub karaoke_type: KaraokeType,
27    /// Whether to automatically detect syllables
28    pub auto_detect_syllables: bool,
29}
30
31/// ASS karaoke tag types
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum KaraokeType {
34    /// \k - standard karaoke (highlights during duration)
35    Standard,
36    /// \kf or \K - fill karaoke (sweeps from left to right)
37    Fill,
38    /// \ko - outline karaoke (outline changes during duration)
39    Outline,
40    /// \kt - transition karaoke (for advanced effects)
41    Transition,
42}
43
44impl KaraokeType {
45    /// Get the ASS tag string for this karaoke type
46    pub fn tag_string(self) -> &'static str {
47        match self {
48            KaraokeType::Standard => "k",
49            KaraokeType::Fill => "kf",
50            KaraokeType::Outline => "ko",
51            KaraokeType::Transition => "kt",
52        }
53    }
54}
55
56impl GenerateKaraokeCommand {
57    /// Create a new generate karaoke command
58    pub fn new(range: Range, default_duration: u32) -> Self {
59        Self {
60            range,
61            default_duration,
62            karaoke_type: KaraokeType::Standard,
63            auto_detect_syllables: true,
64        }
65    }
66
67    /// Set the karaoke type
68    #[must_use]
69    pub fn karaoke_type(mut self, karaoke_type: KaraokeType) -> Self {
70        self.karaoke_type = karaoke_type;
71        self
72    }
73
74    /// Disable automatic syllable detection
75    #[must_use]
76    pub fn manual_syllables(mut self) -> Self {
77        self.auto_detect_syllables = false;
78        self
79    }
80
81    /// Split text into syllables automatically
82    fn split_into_syllables(&self, text: &str) -> Vec<String> {
83        if !self.auto_detect_syllables {
84            return vec![text.to_string()];
85        }
86
87        // Simple syllable detection based on vowels and common patterns
88        let mut syllables = Vec::new();
89        let mut current_start = 0;
90        let chars: Vec<char> = text.chars().collect();
91
92        if chars.is_empty() {
93            return vec![text.to_string()];
94        }
95
96        for (i, &ch) in chars.iter().enumerate() {
97            // Split on spaces and common syllable boundaries
98            if ch.is_whitespace() || (i > 0 && self.is_syllable_boundary(&chars, i)) {
99                if current_start < i {
100                    let syllable: String = chars[current_start..i].iter().collect();
101                    if !syllable.trim().is_empty() {
102                        syllables.push(syllable);
103                    }
104                }
105
106                // Handle whitespace
107                if ch.is_whitespace() {
108                    let mut end = i + 1;
109                    while end < chars.len() && chars[end].is_whitespace() {
110                        end += 1;
111                    }
112                    if end > i + 1 {
113                        let whitespace: String = chars[i..end].iter().collect();
114                        syllables.push(whitespace);
115                        current_start = end;
116                        continue;
117                    }
118                }
119
120                current_start = i;
121            }
122        }
123
124        // Add remaining text
125        if current_start < chars.len() {
126            let remaining: String = chars[current_start..].iter().collect();
127            if !remaining.trim().is_empty() {
128                syllables.push(remaining);
129            }
130        }
131
132        // Return syllables or whole text if none found
133        if syllables.is_empty() {
134            vec![text.to_string()]
135        } else {
136            syllables
137        }
138    }
139
140    /// Check if position is a syllable boundary
141    fn is_syllable_boundary(&self, chars: &[char], pos: usize) -> bool {
142        if pos == 0 || pos >= chars.len() {
143            return false;
144        }
145
146        let prev = chars[pos - 1];
147        let curr = chars[pos];
148
149        // Split on vowel-consonant or consonant-vowel boundaries
150        let prev_vowel = "aeiouAEIOU".contains(prev);
151        let curr_vowel = "aeiouAEIOU".contains(curr);
152
153        // Simple heuristic: split when transitioning from vowel to consonant
154        // or when encountering certain consonant clusters
155        prev_vowel && !curr_vowel && !curr.is_whitespace()
156    }
157}
158
159impl EditorCommand for GenerateKaraokeCommand {
160    fn execute(&self, document: &mut EditorDocument) -> Result<CommandResult> {
161        let original_text = document.text_range(self.range)?;
162
163        // Skip if text is already in override blocks
164        if original_text.contains('{') || original_text.contains('}') {
165            return Err(EditorError::command_failed(
166                "Cannot generate karaoke for text containing override blocks",
167            ));
168        }
169
170        let syllables = self.split_into_syllables(&original_text);
171        let tag = self.karaoke_type.tag_string();
172
173        let mut karaoke_text = String::new();
174        for (i, syllable) in syllables.iter().enumerate() {
175            if i == 0 {
176                // First syllable gets the karaoke tag
177                karaoke_text.push_str(&format!("{{\\{tag}{}}}", self.default_duration));
178            } else if !syllable.trim().is_empty() {
179                // Subsequent syllables get their own timing
180                karaoke_text.push_str(&format!("{{\\{tag}{}}}", self.default_duration));
181            }
182            karaoke_text.push_str(syllable);
183        }
184
185        document.replace_raw(self.range, &karaoke_text)?;
186
187        let end_pos = Position::new(self.range.start.offset + karaoke_text.len());
188        let range = Range::new(self.range.start, end_pos);
189
190        Ok(CommandResult::success_with_change(range, end_pos))
191    }
192
193    fn description(&self) -> &str {
194        "Generate karaoke timing"
195    }
196
197    fn memory_usage(&self) -> usize {
198        core::mem::size_of::<Self>()
199    }
200}
201
202/// Split karaoke timing at specific points
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct SplitKaraokeCommand {
205    /// Range containing karaoke to split
206    pub range: Range,
207    /// Character positions to split at (relative to range start)
208    pub split_positions: Vec<usize>,
209    /// New duration for each split segment
210    pub new_duration: Option<u32>,
211}
212
213impl SplitKaraokeCommand {
214    /// Create a new split karaoke command
215    pub fn new(range: Range, split_positions: Vec<usize>) -> Self {
216        Self {
217            range,
218            split_positions,
219            new_duration: None,
220        }
221    }
222
223    /// Set new duration for split segments
224    #[must_use]
225    pub fn duration(mut self, duration: u32) -> Self {
226        self.new_duration = Some(duration);
227        self
228    }
229}
230
231impl EditorCommand for SplitKaraokeCommand {
232    fn execute(&self, document: &mut EditorDocument) -> Result<CommandResult> {
233        let original_text = document.text_range(self.range)?;
234        let processed_text = self.split_karaoke_text(&original_text)?;
235
236        document.replace_raw(self.range, &processed_text)?;
237
238        let end_pos = Position::new(self.range.start.offset + processed_text.len());
239        let range = Range::new(self.range.start, end_pos);
240
241        Ok(CommandResult::success_with_change(range, end_pos))
242    }
243
244    fn description(&self) -> &str {
245        "Split karaoke timing"
246    }
247
248    fn memory_usage(&self) -> usize {
249        core::mem::size_of::<Self>() + self.split_positions.len() * core::mem::size_of::<usize>()
250    }
251}
252
253impl SplitKaraokeCommand {
254    /// Split karaoke text at specified positions
255    fn split_karaoke_text(&self, text: &str) -> Result<String> {
256        // For now, return simplified version
257        // In practice, this would parse existing karaoke tags and split them
258        let mut result = String::new();
259        let mut last_pos = 0;
260
261        for &pos in &self.split_positions {
262            if pos <= last_pos || pos >= text.len() {
263                continue;
264            }
265
266            let segment = &text[last_pos..pos];
267            if !segment.is_empty() {
268                let duration = self.new_duration.unwrap_or(50);
269                result.push_str(&format!("{{\\k{duration}}}{segment}"));
270            }
271            last_pos = pos;
272        }
273
274        // Add remaining text
275        if last_pos < text.len() {
276            let segment = &text[last_pos..];
277            if !segment.is_empty() {
278                let duration = self.new_duration.unwrap_or(50);
279                result.push_str(&format!("{{\\k{duration}}}{segment}"));
280            }
281        }
282
283        Ok(result)
284    }
285}
286
287/// Adjust timing of existing karaoke tags
288#[derive(Debug, Clone, PartialEq)]
289pub struct AdjustKaraokeCommand {
290    /// Range containing karaoke to adjust
291    pub range: Range,
292    /// Timing adjustment operation
293    pub adjustment: TimingAdjustment,
294}
295
296/// Karaoke timing adjustment operations
297#[derive(Debug, Clone, PartialEq)]
298pub enum TimingAdjustment {
299    /// Scale all timings by a factor (e.g., 1.2 to make 20% longer)
300    Scale(f32),
301    /// Add/subtract centiseconds to/from all timings
302    Offset(i32),
303    /// Set all timings to a specific duration
304    SetAll(u32),
305    /// Apply custom timing to each syllable
306    Custom(Vec<u32>),
307}
308
309impl AdjustKaraokeCommand {
310    /// Create a scaling adjustment command
311    pub fn scale(range: Range, factor: f32) -> Self {
312        Self {
313            range,
314            adjustment: TimingAdjustment::Scale(factor),
315        }
316    }
317
318    /// Create an offset adjustment command
319    pub fn offset(range: Range, offset: i32) -> Self {
320        Self {
321            range,
322            adjustment: TimingAdjustment::Offset(offset),
323        }
324    }
325
326    /// Create a set-all adjustment command
327    pub fn set_all(range: Range, duration: u32) -> Self {
328        Self {
329            range,
330            adjustment: TimingAdjustment::SetAll(duration),
331        }
332    }
333
334    /// Create a custom timing adjustment command
335    pub fn custom(range: Range, timings: Vec<u32>) -> Self {
336        Self {
337            range,
338            adjustment: TimingAdjustment::Custom(timings),
339        }
340    }
341}
342
343impl EditorCommand for AdjustKaraokeCommand {
344    fn execute(&self, document: &mut EditorDocument) -> Result<CommandResult> {
345        let original_text = document.text_range(self.range)?;
346        let adjusted_text = self.adjust_karaoke_timing(&original_text)?;
347
348        document.replace_raw(self.range, &adjusted_text)?;
349
350        let end_pos = Position::new(self.range.start.offset + adjusted_text.len());
351        let range = Range::new(self.range.start, end_pos);
352
353        Ok(CommandResult::success_with_change(range, end_pos))
354    }
355
356    fn description(&self) -> &str {
357        match self.adjustment {
358            TimingAdjustment::Scale(_) => "Scale karaoke timing",
359            TimingAdjustment::Offset(_) => "Offset karaoke timing",
360            TimingAdjustment::SetAll(_) => "Set karaoke timing",
361            TimingAdjustment::Custom(_) => "Apply custom karaoke timing",
362        }
363    }
364
365    fn memory_usage(&self) -> usize {
366        let adjustment_size = match &self.adjustment {
367            TimingAdjustment::Custom(vec) => vec.len() * core::mem::size_of::<u32>(),
368            _ => 0,
369        };
370        core::mem::size_of::<Self>() + adjustment_size
371    }
372}
373
374impl AdjustKaraokeCommand {
375    /// Adjust karaoke timing in text using ass-core's ExtensionRegistry system
376    fn adjust_karaoke_timing(&self, text: &str) -> Result<String> {
377        use ass_core::analysis::events::tags::parse_override_block_with_registry;
378        use ass_core::plugin::{tags::karaoke::create_karaoke_handlers, ExtensionRegistry};
379
380        // Create registry with karaoke handlers
381        let mut registry = ExtensionRegistry::new();
382        for handler in create_karaoke_handlers() {
383            registry.register_tag_handler(handler).map_err(|e| {
384                crate::core::errors::EditorError::ValidationError {
385                    message: format!("Failed to register karaoke handler: {e:?}"),
386                }
387            })?;
388        }
389
390        let mut result = String::new();
391        let mut chars = text.chars().peekable();
392        let mut custom_index = 0;
393
394        while let Some(ch) = chars.next() {
395            if ch == '{' {
396                // Found override block - extract content
397                let mut override_content = String::new();
398                let mut brace_count = 1;
399
400                for inner_ch in chars.by_ref() {
401                    if inner_ch == '{' {
402                        brace_count += 1;
403                    } else if inner_ch == '}' {
404                        brace_count -= 1;
405                        if brace_count == 0 {
406                            break;
407                        }
408                    }
409                    override_content.push(inner_ch);
410                }
411
412                // Use ass-core's registry-based parser
413                let mut tags = Vec::new();
414                let mut diagnostics = Vec::new();
415                parse_override_block_with_registry(
416                    &override_content,
417                    0,
418                    &mut tags,
419                    &mut diagnostics,
420                    Some(&registry),
421                );
422
423                // Process karaoke tags using ass-core's validated data
424                let processed_content = self.adjust_karaoke_tags_with_registry(
425                    &override_content,
426                    &tags,
427                    &mut custom_index,
428                )?;
429
430                result.push('{');
431                result.push_str(&processed_content);
432                result.push('}');
433            } else {
434                result.push(ch);
435            }
436        }
437
438        Ok(result)
439    }
440
441    /// Adjust karaoke tags using registry-validated tag information
442    fn adjust_karaoke_tags_with_registry(
443        &self,
444        original_content: &str,
445        tags: &[ass_core::analysis::events::tags::OverrideTag],
446        custom_index: &mut usize,
447    ) -> Result<String> {
448        let mut result = original_content.to_string();
449
450        // Process tags in reverse order to maintain position accuracy
451        for tag in tags.iter().rev() {
452            if tag.name().starts_with('k') {
453                // This tag was validated by ass-core's karaoke handlers
454                let tag_name = tag.name();
455                let args = tag.args();
456
457                // Extract duration from args (ass-core already validated this)
458                let current_duration: u32 = args.trim().parse().unwrap_or(0);
459
460                // Calculate new duration based on adjustment type
461                let new_duration = match &self.adjustment {
462                    TimingAdjustment::Scale(factor) => {
463                        ((current_duration as f32 * factor) as u32).max(1)
464                    }
465                    TimingAdjustment::Offset(offset) => {
466                        ((current_duration as i32 + offset).max(1)) as u32
467                    }
468                    TimingAdjustment::SetAll(duration) => *duration,
469                    TimingAdjustment::Custom(timings) => {
470                        if *custom_index < timings.len() {
471                            let timing = timings[*custom_index];
472                            *custom_index += 1;
473                            timing
474                        } else {
475                            current_duration
476                        }
477                    }
478                };
479
480                // Replace the validated tag with adjusted version
481                let old_tag = format!("\\{tag_name}{current_duration}");
482                let new_tag = format!("\\{tag_name}{new_duration}");
483                result = result.replace(&old_tag, &new_tag);
484            }
485        }
486
487        Ok(result)
488    }
489}
490
491/// Apply karaoke timing to event text
492#[derive(Debug, Clone, PartialEq)]
493pub struct ApplyKaraokeCommand {
494    /// Event range to apply karaoke to
495    pub event_range: Range,
496    /// Karaoke template or pattern to apply
497    pub karaoke_template: KaraokeTemplate,
498}
499
500/// Karaoke template for applying timing patterns
501#[derive(Debug, Clone, PartialEq)]
502pub enum KaraokeTemplate {
503    /// Simple equal timing for all syllables
504    Equal {
505        syllable_duration: u32,
506        karaoke_type: KaraokeType,
507    },
508    /// Beat-based timing (e.g., 4/4 time signature)
509    Beat {
510        beats_per_minute: u32,
511        beats_per_syllable: f32,
512        karaoke_type: KaraokeType,
513    },
514    /// Custom timing pattern that repeats
515    Pattern {
516        durations: Vec<u32>,
517        karaoke_type: KaraokeType,
518    },
519    /// Import timing from another event
520    ImportFrom { source_event_index: usize },
521}
522
523impl ApplyKaraokeCommand {
524    /// Create an equal timing command
525    pub fn equal(event_range: Range, duration: u32, karaoke_type: KaraokeType) -> Self {
526        Self {
527            event_range,
528            karaoke_template: KaraokeTemplate::Equal {
529                syllable_duration: duration,
530                karaoke_type,
531            },
532        }
533    }
534
535    /// Create a beat-based timing command
536    pub fn beat(
537        event_range: Range,
538        bpm: u32,
539        beats_per_syllable: f32,
540        karaoke_type: KaraokeType,
541    ) -> Self {
542        Self {
543            event_range,
544            karaoke_template: KaraokeTemplate::Beat {
545                beats_per_minute: bpm,
546                beats_per_syllable,
547                karaoke_type,
548            },
549        }
550    }
551
552    /// Create a pattern-based timing command
553    pub fn pattern(event_range: Range, durations: Vec<u32>, karaoke_type: KaraokeType) -> Self {
554        Self {
555            event_range,
556            karaoke_template: KaraokeTemplate::Pattern {
557                durations,
558                karaoke_type,
559            },
560        }
561    }
562
563    /// Create an import timing command
564    pub fn import_from(event_range: Range, source_event_index: usize) -> Self {
565        Self {
566            event_range,
567            karaoke_template: KaraokeTemplate::ImportFrom { source_event_index },
568        }
569    }
570}
571
572impl EditorCommand for ApplyKaraokeCommand {
573    fn execute(&self, document: &mut EditorDocument) -> Result<CommandResult> {
574        let original_text = document.text_range(self.event_range)?;
575        let karaoke_text = self.apply_karaoke_template(&original_text, document)?;
576
577        document.replace_raw(self.event_range, &karaoke_text)?;
578
579        let end_pos = Position::new(self.event_range.start.offset + karaoke_text.len());
580        let range = Range::new(self.event_range.start, end_pos);
581
582        Ok(CommandResult::success_with_change(range, end_pos))
583    }
584
585    fn description(&self) -> &str {
586        match &self.karaoke_template {
587            KaraokeTemplate::Equal { .. } => "Apply equal karaoke timing",
588            KaraokeTemplate::Beat { .. } => "Apply beat-based karaoke timing",
589            KaraokeTemplate::Pattern { .. } => "Apply pattern-based karaoke timing",
590            KaraokeTemplate::ImportFrom { .. } => "Import karaoke timing",
591        }
592    }
593
594    fn memory_usage(&self) -> usize {
595        let template_size = match &self.karaoke_template {
596            KaraokeTemplate::Pattern { durations, .. } => {
597                durations.len() * core::mem::size_of::<u32>()
598            }
599            _ => 0,
600        };
601        core::mem::size_of::<Self>() + template_size
602    }
603}
604
605impl ApplyKaraokeCommand {
606    /// Apply karaoke template to text
607    fn apply_karaoke_template(&self, text: &str, _document: &EditorDocument) -> Result<String> {
608        // Extract text content from event (skip override blocks for syllable detection)
609        let clean_text = self.extract_clean_text(text);
610        let syllables = self.detect_syllables(&clean_text);
611
612        match &self.karaoke_template {
613            KaraokeTemplate::Equal {
614                syllable_duration,
615                karaoke_type,
616            } => self.apply_equal_timing(&syllables, *syllable_duration, *karaoke_type),
617            KaraokeTemplate::Beat {
618                beats_per_minute,
619                beats_per_syllable,
620                karaoke_type,
621            } => self.apply_beat_timing(
622                &syllables,
623                *beats_per_minute,
624                *beats_per_syllable,
625                *karaoke_type,
626            ),
627            KaraokeTemplate::Pattern {
628                durations,
629                karaoke_type,
630            } => self.apply_pattern_timing(&syllables, durations, *karaoke_type),
631            KaraokeTemplate::ImportFrom {
632                source_event_index: _,
633            } => {
634                // Simplified - would need to parse other events
635                Ok(text.to_string())
636            }
637        }
638    }
639
640    /// Extract clean text without override blocks
641    fn extract_clean_text(&self, text: &str) -> String {
642        let mut result = String::new();
643        let mut chars = text.chars();
644
645        while let Some(ch) = chars.next() {
646            if ch == '{' {
647                // Skip override block
648                let mut brace_count = 1;
649                for inner_ch in chars.by_ref() {
650                    if inner_ch == '{' {
651                        brace_count += 1;
652                    } else if inner_ch == '}' {
653                        brace_count -= 1;
654                        if brace_count == 0 {
655                            break;
656                        }
657                    }
658                }
659            } else {
660                result.push(ch);
661            }
662        }
663
664        result
665    }
666
667    /// Detect syllables in clean text
668    fn detect_syllables(&self, text: &str) -> Vec<String> {
669        // Simple syllable detection - split on spaces and vowel boundaries
670        text.split_whitespace()
671            .flat_map(|word| {
672                // For now, treat each word as one syllable
673                // In practice, you'd want more sophisticated syllable detection
674                vec![word.to_string()]
675            })
676            .collect()
677    }
678
679    /// Apply equal timing to syllables
680    fn apply_equal_timing(
681        &self,
682        syllables: &[String],
683        duration: u32,
684        karaoke_type: KaraokeType,
685    ) -> Result<String> {
686        let tag = karaoke_type.tag_string();
687        let mut result = String::new();
688
689        for (i, syllable) in syllables.iter().enumerate() {
690            if i > 0 {
691                result.push(' '); // Add space between syllables
692            }
693            result.push_str(&format!("{{\\{tag}{duration}}}{syllable}"));
694        }
695
696        Ok(result)
697    }
698
699    /// Apply beat-based timing to syllables
700    fn apply_beat_timing(
701        &self,
702        syllables: &[String],
703        bpm: u32,
704        beats_per_syllable: f32,
705        karaoke_type: KaraokeType,
706    ) -> Result<String> {
707        // Calculate duration in centiseconds: (60 seconds / BPM) * beats_per_syllable * 100 cs/s
708        let duration = ((60.0 / bpm as f32) * beats_per_syllable * 100.0) as u32;
709        self.apply_equal_timing(syllables, duration, karaoke_type)
710    }
711
712    /// Apply pattern-based timing to syllables
713    fn apply_pattern_timing(
714        &self,
715        syllables: &[String],
716        durations: &[u32],
717        karaoke_type: KaraokeType,
718    ) -> Result<String> {
719        let tag = karaoke_type.tag_string();
720        let mut result = String::new();
721
722        for (i, syllable) in syllables.iter().enumerate() {
723            if i > 0 {
724                result.push(' ');
725            }
726            let duration = durations.get(i % durations.len()).copied().unwrap_or(50);
727            result.push_str(&format!("{{\\{tag}{duration}}}{syllable}"));
728        }
729
730        Ok(result)
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::core::EditorDocument;
738    #[cfg(not(feature = "std"))]
739    use alloc::vec;
740    #[cfg(not(feature = "std"))]
741    #[test]
742    fn generate_karaoke_basic() {
743        let mut doc = EditorDocument::from_content("Hello World").unwrap();
744        let range = Range::new(Position::new(0), Position::new(11));
745        let command = GenerateKaraokeCommand::new(range, 50);
746
747        let result = command.execute(&mut doc).unwrap();
748        assert!(result.success);
749        assert!(doc.text().contains("\\k50"));
750    }
751
752    #[test]
753    fn split_karaoke() {
754        let mut doc = EditorDocument::from_content("Hello World").unwrap();
755        let range = Range::new(Position::new(0), Position::new(11));
756        let command = SplitKaraokeCommand::new(range, vec![5]).duration(30);
757
758        let result = command.execute(&mut doc).unwrap();
759        assert!(result.success);
760        assert!(doc.text().contains("\\k30"));
761    }
762
763    #[test]
764    fn adjust_karaoke_scale() {
765        let mut doc = EditorDocument::from_content("{\\k50}Hello").unwrap();
766        let range = Range::new(Position::new(0), Position::new(doc.text().len()));
767        let command = AdjustKaraokeCommand::scale(range, 2.0);
768
769        let result = command.execute(&mut doc).unwrap();
770        assert!(result.success);
771        assert!(doc.text().contains("\\k100"));
772    }
773
774    #[test]
775    fn apply_karaoke_equal() {
776        let mut doc = EditorDocument::from_content("Hello World").unwrap();
777        let range = Range::new(Position::new(0), Position::new(11));
778        let command = ApplyKaraokeCommand::equal(range, 40, KaraokeType::Fill);
779
780        let result = command.execute(&mut doc).unwrap();
781        assert!(result.success);
782        assert!(doc.text().contains("\\kf40"));
783    }
784
785    #[test]
786    fn apply_karaoke_beat() {
787        let mut doc = EditorDocument::from_content("Hello World").unwrap();
788        let range = Range::new(Position::new(0), Position::new(11));
789        let command = ApplyKaraokeCommand::beat(range, 120, 0.5, KaraokeType::Standard);
790
791        let result = command.execute(&mut doc).unwrap();
792        assert!(result.success);
793        // Beat timing: (60/120) * 0.5 * 100 = 25 centiseconds
794        assert!(doc.text().contains("\\k25"));
795    }
796
797    #[test]
798    fn karaoke_types() {
799        assert_eq!(KaraokeType::Standard.tag_string(), "k");
800        assert_eq!(KaraokeType::Fill.tag_string(), "kf");
801        assert_eq!(KaraokeType::Outline.tag_string(), "ko");
802        assert_eq!(KaraokeType::Transition.tag_string(), "kt");
803    }
804}