@@ -670,6 +670,153 @@ def test_multiblank_rnnt_greedy_decoding(self, greedy_class):
670670 with torch .no_grad ():
671671 _ = greedy (encoder_output = enc_out , encoded_lengths = enc_len )
672672
673+ @pytest .mark .skipif (
674+ not NUMBA_RNNT_LOSS_AVAILABLE ,
675+ reason = 'RNNTLoss has not been compiled with appropriate numba version.' ,
676+ )
677+ @pytest .mark .unit
678+ @pytest .mark .parametrize ("max_symbols_per_step" , [1 , 2 , 5 ])
679+ def test_tdt_greedy_decoding_advances_by_predicted_duration (self , max_symbols_per_step : int ):
680+ """TDT greedy decoding must advance the time index by exactly the predicted duration.
681+
682+ A joint that always predicts token 1 with duration 2 makes the answer computable by hand:
683+ the tokens have to land on t = 0, 2, 4, ... for every value of `max_symbols_per_step`.
684+ """
685+ token_list = [" " , "a" , "b" , "c" ]
686+ vocab_size = len (token_list )
687+ durations = [0 , 1 , 2 , 4 ]
688+ duration_index = 2
689+ num_frames = 10
690+
691+ encoder_output_size = 4
692+ decoder_output_size = 4
693+ joint_output_shape = 4
694+
695+ class ConstantTDTJoint (RNNTJoint ):
696+ """Joint that always predicts token 1 with duration `durations[duration_index]`."""
697+
698+ def joint_after_projection (self , f : torch .Tensor , g : torch .Tensor ) -> torch .Tensor :
699+ batch_size = f .shape [0 ]
700+ time_steps = f .shape [1 ] if f .dim () == 3 else 1
701+ target_steps = g .shape [1 ] if g .dim () == 3 else 1
702+ logits = torch .full (
703+ (batch_size , time_steps , target_steps , vocab_size + 1 + len (durations )), - 10.0 , device = f .device
704+ )
705+ logits [..., 1 ] = 5.0
706+ logits [..., vocab_size + 1 + duration_index ] = 5.0
707+ return logits
708+
709+ prednet_cfg = {'pred_hidden' : decoder_output_size , 'pred_rnn_layers' : 1 }
710+ jointnet_cfg = {
711+ 'encoder_hidden' : encoder_output_size ,
712+ 'pred_hidden' : decoder_output_size ,
713+ 'joint_hidden' : joint_output_shape ,
714+ 'activation' : 'relu' ,
715+ }
716+
717+ decoder = RNNTDecoder (prednet_cfg , vocab_size )
718+ joint_net = ConstantTDTJoint (jointnet_cfg , vocab_size , vocabulary = token_list , num_extra_outputs = len (durations ))
719+
720+ duration = durations [duration_index ]
721+ expected_timestamp = list (range (0 , num_frames , duration ))
722+
723+ # (B, D, T)
724+ enc_out = torch .zeros (1 , encoder_output_size , num_frames )
725+ enc_len = torch .tensor ([num_frames ], dtype = torch .int32 )
726+
727+ for greedy_class , additional_decoding_kwargs in [
728+ (greedy_decode .GreedyTDTInfer , {}),
729+ (greedy_decode .GreedyBatchedTDTInfer , {"use_cuda_graph_decoder" : False }),
730+ ]:
731+ greedy = greedy_class (
732+ decoder ,
733+ joint_net ,
734+ blank_index = vocab_size ,
735+ durations = durations ,
736+ max_symbols_per_step = max_symbols_per_step ,
737+ include_duration = True ,
738+ ** additional_decoding_kwargs ,
739+ )
740+
741+ with torch .no_grad ():
742+ hyp = greedy (encoder_output = enc_out , encoded_lengths = enc_len )[0 ][0 ]
743+
744+ assert [int (t ) for t in hyp .timestamp ] == expected_timestamp , greedy_class .__name__
745+ assert [int (d ) for d in hyp .token_duration ] == [duration ] * len (expected_timestamp )
746+
747+ @pytest .mark .skipif (
748+ not NUMBA_RNNT_LOSS_AVAILABLE ,
749+ reason = 'RNNTLoss has not been compiled with appropriate numba version.' ,
750+ )
751+ @pytest .mark .unit
752+ def test_tdt_greedy_decoding_exhausted_symbol_budget (self ):
753+ """Exhausting the symbol budget on a non-zero duration must not advance the time index further.
754+
755+ The joint alternates duration 0 and duration 2 on successive calls, so with
756+ `max_symbols_per_step=2` every time step emits one token with duration 0 and one with duration 2,
757+ exhausting the budget exactly when the time index has already advanced by 2.
758+ """
759+ token_list = [" " , "a" , "b" , "c" ]
760+ vocab_size = len (token_list )
761+ durations = [0 , 1 , 2 , 4 ]
762+ num_frames = 10
763+
764+ encoder_output_size = 4
765+ decoder_output_size = 4
766+ joint_output_shape = 4
767+
768+ class AlternatingTDTJoint (RNNTJoint ):
769+ """Joint that always predicts token 1, with duration 0 and 2 on alternating calls."""
770+
771+ def __init__ (self , * args , ** kwargs ):
772+ super ().__init__ (* args , ** kwargs )
773+ self .num_calls = 0
774+
775+ def joint_after_projection (self , f : torch .Tensor , g : torch .Tensor ) -> torch .Tensor :
776+ batch_size = f .shape [0 ]
777+ time_steps = f .shape [1 ] if f .dim () == 3 else 1
778+ target_steps = g .shape [1 ] if g .dim () == 3 else 1
779+ logits = torch .full (
780+ (batch_size , time_steps , target_steps , vocab_size + 1 + len (durations )), - 10.0 , device = f .device
781+ )
782+ logits [..., 1 ] = 5.0
783+ logits [..., vocab_size + 1 + (0 if self .num_calls % 2 == 0 else 2 )] = 5.0
784+ self .num_calls += 1
785+ return logits
786+
787+ prednet_cfg = {'pred_hidden' : decoder_output_size , 'pred_rnn_layers' : 1 }
788+ jointnet_cfg = {
789+ 'encoder_hidden' : encoder_output_size ,
790+ 'pred_hidden' : decoder_output_size ,
791+ 'joint_hidden' : joint_output_shape ,
792+ 'activation' : 'relu' ,
793+ }
794+
795+ decoder = RNNTDecoder (prednet_cfg , vocab_size )
796+ joint_net = AlternatingTDTJoint (
797+ jointnet_cfg , vocab_size , vocabulary = token_list , num_extra_outputs = len (durations )
798+ )
799+
800+ greedy = greedy_decode .GreedyTDTInfer (
801+ decoder ,
802+ joint_net ,
803+ blank_index = vocab_size ,
804+ durations = durations ,
805+ max_symbols_per_step = 2 ,
806+ include_duration = True ,
807+ )
808+
809+ # (B, D, T)
810+ enc_out = torch .zeros (1 , encoder_output_size , num_frames )
811+ enc_len = torch .tensor ([num_frames ], dtype = torch .int32 )
812+
813+ with torch .no_grad ():
814+ hyp = greedy (encoder_output = enc_out , encoded_lengths = enc_len )[0 ][0 ]
815+
816+ # two tokens per time step, and the time step advances by the duration of the second one
817+ assert [int (t ) for t in hyp .timestamp ] == [t for t in range (0 , num_frames , 2 ) for _ in range (2 )]
818+ assert [int (d ) for d in hyp .token_duration ] == [0 , 2 ] * (num_frames // 2 )
819+
673820 @pytest .mark .skipif (
674821 not NUMBA_RNNT_LOSS_AVAILABLE ,
675822 reason = 'RNNTLoss has not been compiled with appropriate numba version.' ,
0 commit comments