@@ -35,6 +35,8 @@ vi.mock("./whisperServer", () => {
3535vi . mock ( "./modelManager" , ( ) => ( {
3636 ensureModels : vi . fn ( async ( ) => undefined ) ,
3737 ensureAligner : vi . fn ( async ( { language } : { language : string } ) => `/fake/${ language } .gguf` ) ,
38+ cachedAligner : vi . fn ( async ( ) => null ) ,
39+ alignerPath : vi . fn ( ( base : string , language : string ) : string | null => `${ base } /${ language } .gguf` ) ,
3840 modelPaths : ( base : string ) => ( {
3941 whisper : `${ base } /whisper-ggml/ggml-small-q8_0.bin` ,
4042 } ) ,
@@ -471,39 +473,108 @@ describe("SttManager", () => {
471473 expect ( fakeWhisperServer . start ) . toHaveBeenCalledOnce ( ) ;
472474 } ) ;
473475
474- it ( "hands the chunks an aligner, tried once per run when its download fails" , async ( ) => {
475- const { ensureAligner } = await import ( "./modelManager" ) ;
476- const mocked = vi . mocked ( ensureAligner ) ;
477- mocked . mockClear ( ) ;
478- mocked . mockRejectedValueOnce ( new Error ( "offline" ) ) ;
479- // Each chunk asks for the aligner of the language it detected, as the helper does.
480- const asked : Array < string | null > = [ ] ;
481- fakeWhisperServer . transcribe . mockImplementation (
482- async ( opts : { alignerFor ?: ( language : string ) => Promise < string | null > } ) => {
483- asked . push ( ( await opts . alignerFor ?.( "fr" ) ) ?? null ) ;
484- return {
485- segments : [ ] ,
486- wordSegments : [ ] ,
487- detectedLanguage : "fr" ,
488- backend : "whispercpp-cpu" as const ,
489- } ;
490- } ,
491- ) ;
492- const mgr = new SttManager ( ) ;
493- await mgr . init ( { modelsBaseDir : "/tmp/fake-stt-models" } ) ;
476+ describe ( "word aligner" , ( ) => {
477+ /** Chunks report what `alignerFor("fr")` gave them, as the helper would use it. */
478+ function recordAligner ( ) {
479+ const asked : Array < string | null > = [ ] ;
480+ fakeWhisperServer . transcribe . mockImplementation (
481+ async ( opts : { alignerFor ?: ( language : string ) => Promise < string | null > } ) => {
482+ asked . push ( ( await opts . alignerFor ?.( "fr" ) ) ?? null ) ;
483+ return {
484+ segments : [ ] ,
485+ wordSegments : [ ] ,
486+ detectedLanguage : "fr" ,
487+ backend : "whispercpp-cpu" as const ,
488+ } ;
489+ } ,
490+ ) ;
491+ return asked ;
492+ }
494493 const long = new Float32Array ( 16_000 * 400 ) ;
495- await mgr . transcribe ( { samples : long , language : "fr" } ) ;
496- // Failed once: the rest of the run keeps whisper's times without retrying.
497- expect ( asked . length ) . toBeGreaterThan ( 1 ) ;
498- expect ( asked . every ( ( x ) => x === null ) ) . toBe ( true ) ;
499- expect ( mocked ) . toHaveBeenCalledOnce ( ) ;
500-
501- // The next run tries again, and a success is kept for the session.
502- asked . length = 0 ;
503- await mgr . transcribe ( { samples : long , language : "fr" } ) ;
504- await mgr . transcribe ( { samples : new Float32Array ( 16_000 ) , language : "fr" } ) ;
505- expect ( asked . every ( ( x ) => x === "/fake/fr.gguf" ) ) . toBe ( true ) ;
506- expect ( mocked ) . toHaveBeenCalledTimes ( 2 ) ;
494+
495+ async function mocks ( ) {
496+ const mm = await import ( "./modelManager" ) ;
497+ const ensure = vi . mocked ( mm . ensureAligner ) ;
498+ const cached = vi . mocked ( mm . cachedAligner ) ;
499+ ensure . mockReset ( ) ;
500+ cached . mockReset ( ) ;
501+ cached . mockResolvedValue ( null ) ;
502+ return { ensure, cached } ;
503+ }
504+
505+ it ( "never holds a chunk on the download, and uses the aligner once it lands" , async ( ) => {
506+ const { ensure } = await mocks ( ) ;
507+ let land : ( file : string ) => void = ( ) => undefined ;
508+ ensure . mockImplementation ( ( ) => new Promise ( ( resolve ) => ( land = resolve ) ) ) ;
509+ const asked = recordAligner ( ) ;
510+ const mgr = new SttManager ( ) ;
511+ await mgr . init ( { modelsBaseDir : "/tmp/fake-stt-models" } ) ;
512+
513+ // The download never finishes during this run: every chunk goes on without it.
514+ await mgr . transcribe ( { samples : long , language : "fr" } ) ;
515+ expect ( asked . length ) . toBeGreaterThan ( 1 ) ;
516+ expect ( asked . every ( ( x ) => x === null ) ) . toBe ( true ) ;
517+ expect ( ensure ) . toHaveBeenCalledOnce ( ) ;
518+
519+ land ( "/fake/fr.gguf" ) ;
520+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
521+ asked . length = 0 ;
522+ await mgr . transcribe ( { samples : new Float32Array ( 16_000 ) , language : "fr" } ) ;
523+ expect ( asked ) . toEqual ( [ "/fake/fr.gguf" ] ) ;
524+ expect ( ensure ) . toHaveBeenCalledOnce ( ) ;
525+ } ) ;
526+
527+ it ( "uses a verified copy already on disk without downloading" , async ( ) => {
528+ const { ensure, cached } = await mocks ( ) ;
529+ cached . mockResolvedValue ( "/fake/fr.gguf" ) ;
530+ const asked = recordAligner ( ) ;
531+ const mgr = new SttManager ( ) ;
532+ await mgr . init ( { modelsBaseDir : "/tmp/fake-stt-models" } ) ;
533+ await mgr . transcribe ( { samples : long , language : "fr" } ) ;
534+ expect ( asked . every ( ( x ) => x === "/fake/fr.gguf" ) ) . toBe ( true ) ;
535+ expect ( cached ) . toHaveBeenCalledOnce ( ) ;
536+ expect ( ensure ) . not . toHaveBeenCalled ( ) ;
537+ } ) ;
538+
539+ it ( "aborts the download on cancel and on shutdown, then retries on a later run" , async ( ) => {
540+ const { ensure } = await mocks ( ) ;
541+ const signals : AbortSignal [ ] = [ ] ;
542+ ensure . mockImplementation (
543+ ( { signal } : { signal ?: AbortSignal } ) =>
544+ new Promise ( ( _ , reject ) => {
545+ if ( signal ) signals . push ( signal ) ;
546+ signal ?. addEventListener ( "abort" , ( ) => reject ( signal . reason ) ) ;
547+ } ) ,
548+ ) ;
549+ recordAligner ( ) ;
550+ const mgr = new SttManager ( ) ;
551+ await mgr . init ( { modelsBaseDir : "/tmp/fake-stt-models" } ) ;
552+ await mgr . transcribe ( { samples : long , language : "fr" } ) ;
553+ expect ( signals ) . toHaveLength ( 1 ) ;
554+
555+ mgr . cancel ( ) ;
556+ expect ( signals [ 0 ] . aborted ) . toBe ( true ) ;
557+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
558+
559+ // Not cached as failed for good: the next transcription starts it again.
560+ await mgr . transcribe ( { samples : long , language : "fr" } ) ;
561+ expect ( signals ) . toHaveLength ( 2 ) ;
562+ expect ( signals [ 1 ] . aborted ) . toBe ( false ) ;
563+ await mgr . shutdown ( ) ;
564+ expect ( signals [ 1 ] . aborted ) . toBe ( true ) ;
565+ } ) ;
566+
567+ it ( "gives a language without an aligner nothing, and downloads nothing" , async ( ) => {
568+ const { ensure } = await mocks ( ) ;
569+ const mm = await import ( "./modelManager" ) ;
570+ vi . mocked ( mm . alignerPath ) . mockReturnValueOnce ( null ) ;
571+ const asked = recordAligner ( ) ;
572+ const mgr = new SttManager ( ) ;
573+ await mgr . init ( { modelsBaseDir : "/tmp/fake-stt-models" } ) ;
574+ await mgr . transcribe ( { samples : new Float32Array ( 16_000 ) , language : "fr" } ) ;
575+ expect ( asked ) . toEqual ( [ null ] ) ;
576+ expect ( ensure ) . not . toHaveBeenCalled ( ) ;
577+ } ) ;
507578 } ) ;
508579
509580 it ( "fans status out to every sink, and detaching one leaves the others" , async ( ) => {
0 commit comments