@medc-com-br/ngx-jaimes-scribe 0.1.8 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, InjectionToken, inject, NgZone, ElementRef, input, output, signal, computed, effect, HostListener, Component } from '@angular/core';
2
+ import { Injectable, InjectionToken, inject, NgZone, signal, ElementRef, input, output, computed, effect, HostListener, Component } from '@angular/core';
3
3
  import { BehaviorSubject, Subject } from 'rxjs';
4
4
  import { CommonModule } from '@angular/common';
5
+ import { ROLE_LABELS } from '@medc-com-br/jaimes-shared';
5
6
 
6
7
  const DEFAULT_CONFIG$2 = {
7
8
  speechThreshold: 8,
@@ -431,6 +432,158 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
431
432
  args: [{ providedIn: 'root' }]
432
433
  }], ctorParameters: () => [] });
433
434
 
435
+ const MIN_CHARS_FOR_IDENTIFICATION = 100;
436
+ const MAX_SAMPLE_SIZE = 20;
437
+ const LOW_CONFIDENCE_THRESHOLD = 0.7;
438
+ const IDENTIFICATION_DEBOUNCE_MS = 2000;
439
+ const DEFAULT_SPEAKER_LABELS = {
440
+ 0: 'Pessoa 1',
441
+ 1: 'Pessoa 2',
442
+ 2: 'Pessoa 3',
443
+ 3: 'Pessoa 4',
444
+ };
445
+ class SpeakerIdentificationService {
446
+ speakerMapping = signal({});
447
+ identifiedSpeakers = signal(new Set());
448
+ isIdentifying = signal(false);
449
+ editingSpeaker = signal(null);
450
+ debounceTimer = null;
451
+ hasLowConfidence(speaker) {
452
+ const mapping = this.speakerMapping()[speaker.toString()];
453
+ return (mapping?.confidence ?? 1) < LOW_CONFIDENCE_THRESHOLD;
454
+ }
455
+ getLabel(speaker, customLabels, doctorName, patientName, companionName) {
456
+ if (customLabels[speaker]) {
457
+ return customLabels[speaker];
458
+ }
459
+ const mapping = this.speakerMapping()[speaker.toString()];
460
+ if (mapping) {
461
+ switch (mapping.role) {
462
+ case 'doctor':
463
+ return doctorName || ROLE_LABELS.doctor;
464
+ case 'patient':
465
+ return patientName || ROLE_LABELS.patient;
466
+ case 'companion':
467
+ return companionName || ROLE_LABELS.companion;
468
+ default:
469
+ return ROLE_LABELS[mapping.role] || `Pessoa ${speaker + 1}`;
470
+ }
471
+ }
472
+ return DEFAULT_SPEAKER_LABELS[speaker] || `Pessoa ${speaker + 1}`;
473
+ }
474
+ getSpeakerStats(entries) {
475
+ const charCount = {};
476
+ const speakers = new Set();
477
+ for (const entry of entries) {
478
+ if (entry.speaker !== undefined && entry.isFinal) {
479
+ speakers.add(entry.speaker);
480
+ charCount[entry.speaker] = (charCount[entry.speaker] || 0) + entry.text.length;
481
+ }
482
+ }
483
+ return {
484
+ speakers: Array.from(speakers),
485
+ uniqueSpeakers: speakers.size,
486
+ charCount,
487
+ };
488
+ }
489
+ shouldIdentify(entries) {
490
+ if (this.isIdentifying())
491
+ return false;
492
+ const stats = this.getSpeakerStats(entries);
493
+ const identifiedSet = this.identifiedSpeakers();
494
+ const hasNewSpeaker = stats.speakers.some((s) => !identifiedSet.has(s) && stats.charCount[s] >= MIN_CHARS_FOR_IDENTIFICATION);
495
+ const allSpeakersHaveEnoughContext = stats.speakers.every((s) => stats.charCount[s] >= MIN_CHARS_FOR_IDENTIFICATION);
496
+ return (stats.uniqueSpeakers >= 2 &&
497
+ allSpeakersHaveEnoughContext &&
498
+ (identifiedSet.size === 0 || hasNewSpeaker));
499
+ }
500
+ scheduleIdentification(callback) {
501
+ if (this.debounceTimer) {
502
+ clearTimeout(this.debounceTimer);
503
+ }
504
+ this.debounceTimer = setTimeout(() => {
505
+ callback();
506
+ }, IDENTIFICATION_DEBOUNCE_MS);
507
+ }
508
+ async identify(entries, apiUrl, token, sessionId, premium) {
509
+ if (entries.length < 3 || !apiUrl)
510
+ return;
511
+ this.isIdentifying.set(true);
512
+ try {
513
+ const sample = entries
514
+ .filter((e) => e.isFinal && e.speaker !== undefined)
515
+ .slice(0, MAX_SAMPLE_SIZE)
516
+ .map((e) => ({ speaker: e.speaker, text: e.text }));
517
+ if (sample.length < 2)
518
+ return;
519
+ const headers = { 'Content-Type': 'application/json' };
520
+ if (token) {
521
+ headers['Authorization'] = `Bearer ${token}`;
522
+ }
523
+ const response = await fetch(`${apiUrl}/identify-speakers`, {
524
+ method: 'POST',
525
+ headers,
526
+ body: JSON.stringify({
527
+ segments: sample,
528
+ context: { sessionId, premium },
529
+ }),
530
+ });
531
+ if (!response.ok) {
532
+ throw new Error(`HTTP ${response.status}`);
533
+ }
534
+ const data = await response.json();
535
+ this.speakerMapping.set(data.speakerMapping);
536
+ const newIdentified = new Set(this.identifiedSpeakers());
537
+ for (const speakerId of Object.keys(data.speakerMapping)) {
538
+ newIdentified.add(parseInt(speakerId, 10));
539
+ }
540
+ this.identifiedSpeakers.set(newIdentified);
541
+ }
542
+ catch (err) {
543
+ console.error('Failed to identify speakers:', err);
544
+ }
545
+ finally {
546
+ this.isIdentifying.set(false);
547
+ }
548
+ }
549
+ setRole(speaker, role) {
550
+ const current = this.speakerMapping();
551
+ const updated = {
552
+ ...current,
553
+ [speaker.toString()]: {
554
+ role,
555
+ confidence: 1,
556
+ isManualOverride: true,
557
+ },
558
+ };
559
+ this.speakerMapping.set(updated);
560
+ this.editingSpeaker.set(null);
561
+ return updated;
562
+ }
563
+ toggleMenu(speaker) {
564
+ const current = this.editingSpeaker();
565
+ this.editingSpeaker.set(current === speaker ? null : speaker);
566
+ }
567
+ closeMenu() {
568
+ this.editingSpeaker.set(null);
569
+ }
570
+ reset() {
571
+ this.speakerMapping.set({});
572
+ this.identifiedSpeakers.set(new Set());
573
+ this.isIdentifying.set(false);
574
+ this.editingSpeaker.set(null);
575
+ if (this.debounceTimer) {
576
+ clearTimeout(this.debounceTimer);
577
+ this.debounceTimer = null;
578
+ }
579
+ }
580
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: SpeakerIdentificationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
581
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: SpeakerIdentificationService });
582
+ }
583
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: SpeakerIdentificationService, decorators: [{
584
+ type: Injectable
585
+ }] });
586
+
434
587
  const SAMPLE_RATE = 16000;
435
588
  const NUM_CHANNELS = 1;
436
589
  const BITS_PER_SAMPLE = 16;
@@ -478,26 +631,207 @@ function formatTime(seconds) {
478
631
  return `${mins}:${secs.toString().padStart(2, '0')}`;
479
632
  }
480
633
 
481
- const SPEAKER_LABELS = {
482
- 0: 'Pessoa 1',
483
- 1: 'Pessoa 2',
484
- 2: 'Pessoa 3',
485
- 3: 'Pessoa 4',
486
- };
634
+ class PlaybackService {
635
+ isPlaying = signal(false);
636
+ isLoading = signal(false);
637
+ currentTime = signal(0);
638
+ duration = signal(0);
639
+ playbackRate = signal(1);
640
+ sessionData = signal(null);
641
+ activeSegmentId = signal(null);
642
+ activeWordIndex = signal(null);
643
+ audioElement = null;
644
+ audioBlobUrl = null;
645
+ elementRef = null;
646
+ eventHandlers = {};
647
+ setElementRef(ref) {
648
+ this.elementRef = ref;
649
+ }
650
+ get segments() {
651
+ return this.sessionData()?.transcription.segments ?? [];
652
+ }
653
+ async loadSession(apiUrl, sessionId, token) {
654
+ this.isLoading.set(true);
655
+ try {
656
+ const headers = {};
657
+ if (token) {
658
+ headers['Authorization'] = `Bearer ${token}`;
659
+ }
660
+ const response = await fetch(`${apiUrl}/session/${sessionId}`, { headers });
661
+ if (!response.ok) {
662
+ throw new Error(`HTTP ${response.status}`);
663
+ }
664
+ const data = await response.json();
665
+ this.sessionData.set(data);
666
+ await this.loadAudio(data.audioUrl);
667
+ }
668
+ finally {
669
+ this.isLoading.set(false);
670
+ }
671
+ }
672
+ async loadAudio(pcmUrl) {
673
+ this.cleanup();
674
+ const response = await fetch(pcmUrl);
675
+ if (!response.ok) {
676
+ throw new Error(`Failed to load audio: HTTP ${response.status}`);
677
+ }
678
+ const pcmData = await response.arrayBuffer();
679
+ const wavData = pcmToWav(pcmData);
680
+ this.audioBlobUrl = createAudioBlobUrl(wavData);
681
+ this.audioElement = new Audio(this.audioBlobUrl);
682
+ this.eventHandlers.loadedmetadata = () => {
683
+ if (this.audioElement) {
684
+ this.duration.set(this.audioElement.duration);
685
+ }
686
+ };
687
+ this.eventHandlers.timeupdate = () => {
688
+ if (this.audioElement) {
689
+ this.currentTime.set(this.audioElement.currentTime);
690
+ this.updateActiveSegment();
691
+ }
692
+ };
693
+ this.eventHandlers.ended = () => {
694
+ this.isPlaying.set(false);
695
+ };
696
+ this.audioElement.addEventListener('loadedmetadata', this.eventHandlers.loadedmetadata);
697
+ this.audioElement.addEventListener('timeupdate', this.eventHandlers.timeupdate);
698
+ this.audioElement.addEventListener('ended', this.eventHandlers.ended);
699
+ }
700
+ togglePlayback() {
701
+ if (!this.audioElement)
702
+ return;
703
+ if (this.isPlaying()) {
704
+ this.audioElement.pause();
705
+ this.isPlaying.set(false);
706
+ }
707
+ else {
708
+ this.audioElement.play();
709
+ this.isPlaying.set(true);
710
+ }
711
+ }
712
+ seekTo(time) {
713
+ if (this.audioElement && !isNaN(time)) {
714
+ this.audioElement.currentTime = time;
715
+ this.currentTime.set(time);
716
+ }
717
+ }
718
+ seekToSegment(segment) {
719
+ if (this.audioElement) {
720
+ this.audioElement.currentTime = segment.startTime;
721
+ this.currentTime.set(segment.startTime);
722
+ if (!this.isPlaying()) {
723
+ this.togglePlayback();
724
+ }
725
+ }
726
+ }
727
+ seekToWord(word) {
728
+ if (this.audioElement) {
729
+ this.audioElement.currentTime = word.start;
730
+ this.currentTime.set(word.start);
731
+ if (!this.isPlaying()) {
732
+ this.togglePlayback();
733
+ }
734
+ }
735
+ }
736
+ setRate(rate) {
737
+ if (isNaN(rate))
738
+ return;
739
+ this.playbackRate.set(rate);
740
+ if (this.audioElement) {
741
+ this.audioElement.playbackRate = rate;
742
+ }
743
+ }
744
+ formatTime(seconds) {
745
+ return formatTime(seconds);
746
+ }
747
+ updateActiveSegment() {
748
+ const time = this.currentTime();
749
+ const segs = this.segments;
750
+ const seg = segs.find((s) => time >= s.startTime && time <= s.endTime);
751
+ this.activeSegmentId.set(seg?.id ?? null);
752
+ if (seg?.words) {
753
+ const wordIdx = seg.words.findIndex((w) => time >= w.start && time <= w.end);
754
+ this.activeWordIndex.set(wordIdx >= 0 ? wordIdx : null);
755
+ }
756
+ else {
757
+ this.activeWordIndex.set(null);
758
+ }
759
+ this.scrollToActiveSegment();
760
+ }
761
+ scrollToActiveSegment() {
762
+ const activeId = this.activeSegmentId();
763
+ if (!activeId || !this.elementRef)
764
+ return;
765
+ const container = this.elementRef.nativeElement.querySelector('.scribe-transcript-playback');
766
+ const activeEl = container?.querySelector(`[data-segment-id="${activeId}"]`);
767
+ if (activeEl && container) {
768
+ const containerRect = container.getBoundingClientRect();
769
+ const activeRect = activeEl.getBoundingClientRect();
770
+ if (activeRect.top < containerRect.top || activeRect.bottom > containerRect.bottom) {
771
+ activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
772
+ }
773
+ }
774
+ }
775
+ cleanup() {
776
+ if (this.audioElement) {
777
+ this.audioElement.pause();
778
+ if (this.eventHandlers.loadedmetadata) {
779
+ this.audioElement.removeEventListener('loadedmetadata', this.eventHandlers.loadedmetadata);
780
+ }
781
+ if (this.eventHandlers.timeupdate) {
782
+ this.audioElement.removeEventListener('timeupdate', this.eventHandlers.timeupdate);
783
+ }
784
+ if (this.eventHandlers.ended) {
785
+ this.audioElement.removeEventListener('ended', this.eventHandlers.ended);
786
+ }
787
+ this.eventHandlers = {};
788
+ this.audioElement = null;
789
+ }
790
+ if (this.audioBlobUrl) {
791
+ URL.revokeObjectURL(this.audioBlobUrl);
792
+ this.audioBlobUrl = null;
793
+ }
794
+ }
795
+ reset() {
796
+ this.cleanup();
797
+ this.isPlaying.set(false);
798
+ this.isLoading.set(false);
799
+ this.currentTime.set(0);
800
+ this.duration.set(0);
801
+ this.playbackRate.set(1);
802
+ this.sessionData.set(null);
803
+ this.activeSegmentId.set(null);
804
+ this.activeWordIndex.set(null);
805
+ }
806
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: PlaybackService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
807
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: PlaybackService });
808
+ }
809
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: PlaybackService, decorators: [{
810
+ type: Injectable
811
+ }] });
812
+
487
813
  class RecorderComponent {
488
814
  audioCapture = inject(AudioCaptureService);
489
815
  socket = inject(ScribeSocketService);
490
816
  elementRef = inject(ElementRef);
817
+ speakerService = inject(SpeakerIdentificationService);
818
+ playback = inject(PlaybackService);
491
819
  subscriptions = [];
492
820
  entryIdCounter = 0;
493
821
  generateAbortController = null;
494
822
  onDocumentClick(event) {
495
- if (!this.showTemplateMenu())
496
- return;
497
823
  const target = event.target;
498
- if (!this.elementRef.nativeElement.contains(target)) {
824
+ const isInside = this.elementRef.nativeElement.contains(target);
825
+ if (this.showTemplateMenu() && !isInside) {
499
826
  this.showTemplateMenu.set(false);
500
827
  }
828
+ if (this.speakerService.editingSpeaker() !== null) {
829
+ const isDropdownClick = target.closest('.scribe-speaker-dropdown') ||
830
+ target.closest('.scribe-speaker-label--editable');
831
+ if (!isDropdownClick) {
832
+ this.speakerService.closeMenu();
833
+ }
834
+ }
501
835
  }
502
836
  wsUrl = input('');
503
837
  token = input('');
@@ -508,6 +842,9 @@ class RecorderComponent {
508
842
  sessionId = input();
509
843
  apiUrl = input('');
510
844
  resumeSessionId = input();
845
+ doctorName = input('');
846
+ patientName = input('');
847
+ companionName = input('');
511
848
  sessionStarted = output();
512
849
  sessionFinished = output();
513
850
  error = output();
@@ -523,21 +860,9 @@ class RecorderComponent {
523
860
  showTemplateMenu = signal(false);
524
861
  isGenerating = signal(false);
525
862
  lastSessionId = signal(null);
526
- isPlaying = signal(false);
527
863
  isLoadingSession = signal(false);
528
- audioCurrentTime = signal(0);
529
- audioDuration = signal(0);
530
- playbackRate = signal(1);
531
- sessionData = signal(null);
532
- activeSegmentId = signal(null);
533
- activeWordIndex = signal(null);
534
- audioElement = null;
535
- audioBlobUrl = null;
536
864
  mode = computed(() => this.sessionId() ? 'playback' : 'recording');
537
- segments = computed(() => this.sessionData()?.transcription.segments ?? []);
538
- hasFinishedSession = computed(() => this.lastSessionId() !== null &&
539
- !this.isRecording() &&
540
- !this.isPaused());
865
+ hasFinishedSession = computed(() => this.lastSessionId() !== null && !this.isRecording() && !this.isPaused());
541
866
  speakerGroups = computed(() => {
542
867
  const allEntries = this.entries();
543
868
  if (allEntries.length === 0)
@@ -554,24 +879,17 @@ class RecorderComponent {
554
879
  return groups;
555
880
  });
556
881
  constructor() {
557
- this.subscriptions.push(this.audioCapture.audioLevel$.subscribe((level) => {
558
- this.audioLevel.set(level);
559
- }), this.socket.connectionState$.subscribe((state) => {
560
- this.connectionState.set(state);
561
- }), this.socket.transcription$.subscribe((event) => {
562
- this.handleTranscription(event);
563
- }), this.socket.error$.subscribe((err) => {
564
- this.error.emit(err);
565
- }), this.audioCapture.error$.subscribe((err) => {
566
- this.error.emit(err);
567
- }), this.audioCapture.audioChunk$.subscribe(({ data, isSilence }) => {
568
- this.socket.sendAudioChunk(data, isSilence);
882
+ this.playback.setElementRef(this.elementRef);
883
+ this.subscriptions.push(this.audioCapture.audioLevel$.subscribe((level) => this.audioLevel.set(level)), this.socket.connectionState$.subscribe((state) => this.connectionState.set(state)), this.socket.transcription$.subscribe((event) => this.handleTranscription(event)), this.socket.error$.subscribe((err) => this.error.emit(err)), this.audioCapture.error$.subscribe((err) => this.error.emit(err)), this.audioCapture.audioChunk$.subscribe(({ data, isSilence }) => {
884
+ if (this.isRecording() && !this.isPaused()) {
885
+ this.socket.sendAudioChunk(data, isSilence);
886
+ }
569
887
  }));
570
888
  effect(() => {
571
889
  const sid = this.sessionId();
572
890
  const api = this.apiUrl();
573
891
  if (sid && api && this.mode() === 'playback') {
574
- this.loadSession();
892
+ this.loadPlaybackSession();
575
893
  }
576
894
  });
577
895
  effect(() => {
@@ -581,56 +899,36 @@ class RecorderComponent {
581
899
  this.resumeSession(resumeId);
582
900
  }
583
901
  });
902
+ effect(() => {
903
+ if (this.mode() !== 'recording')
904
+ return;
905
+ const allEntries = this.entries();
906
+ if (this.speakerService.shouldIdentify(allEntries)) {
907
+ this.speakerService.scheduleIdentification(() => this.triggerIdentification());
908
+ }
909
+ });
584
910
  }
585
911
  ngOnDestroy() {
586
912
  this.subscriptions.forEach((sub) => sub.unsubscribe());
587
913
  this.generateAbortController?.abort();
588
914
  this.cleanup();
589
- this.cleanupPlayback();
590
- }
591
- audioEventHandlers = {};
592
- cleanupPlayback() {
593
- if (this.audioElement) {
594
- this.audioElement.pause();
595
- if (this.audioEventHandlers.loadedmetadata) {
596
- this.audioElement.removeEventListener('loadedmetadata', this.audioEventHandlers.loadedmetadata);
597
- }
598
- if (this.audioEventHandlers.timeupdate) {
599
- this.audioElement.removeEventListener('timeupdate', this.audioEventHandlers.timeupdate);
600
- }
601
- if (this.audioEventHandlers.ended) {
602
- this.audioElement.removeEventListener('ended', this.audioEventHandlers.ended);
603
- }
604
- this.audioEventHandlers = {};
605
- this.audioElement = null;
606
- }
607
- if (this.audioBlobUrl) {
608
- URL.revokeObjectURL(this.audioBlobUrl);
609
- this.audioBlobUrl = null;
610
- }
915
+ this.playback.cleanup();
916
+ this.speakerService.reset();
611
917
  }
612
918
  getSpeakerLabel(speaker) {
613
- const customLabels = this.speakerLabels();
614
- if (customLabels[speaker]) {
615
- return customLabels[speaker];
616
- }
617
- return SPEAKER_LABELS[speaker] || `Pessoa ${speaker + 1}`;
919
+ return this.speakerService.getLabel(speaker, this.speakerLabels(), this.doctorName(), this.patientName(), this.companionName());
618
920
  }
619
921
  shouldShowSpeakerLabel(segmentIndex) {
620
- const segs = this.segments();
922
+ const segs = this.playback.segments;
621
923
  if (segmentIndex === 0)
622
924
  return true;
623
- const currentSpeaker = segs[segmentIndex]?.speaker;
624
- const previousSpeaker = segs[segmentIndex - 1]?.speaker;
625
- return currentSpeaker !== previousSpeaker;
925
+ return segs[segmentIndex]?.speaker !== segs[segmentIndex - 1]?.speaker;
626
926
  }
627
927
  getMicButtonLabel() {
628
- if (this.isPaused()) {
928
+ if (this.isPaused())
629
929
  return 'Retomar gravação';
630
- }
631
- if (this.isRecording()) {
930
+ if (this.isRecording())
632
931
  return 'Pausar gravação';
633
- }
634
932
  return 'Iniciar gravação';
635
933
  }
636
934
  async toggleRecording() {
@@ -698,10 +996,7 @@ class RecorderComponent {
698
996
  method: 'POST',
699
997
  headers,
700
998
  signal: this.generateAbortController.signal,
701
- body: JSON.stringify({
702
- sessionId,
703
- outputSchema: template.content,
704
- }),
999
+ body: JSON.stringify({ sessionId, outputSchema: template.content }),
705
1000
  });
706
1001
  if (!response.ok) {
707
1002
  const errorData = await response.json().catch(() => ({}));
@@ -724,32 +1019,51 @@ class RecorderComponent {
724
1019
  this.isGenerating.set(false);
725
1020
  }
726
1021
  }
727
- async loadSession() {
1022
+ onSeek(event) {
1023
+ const input = event.target;
1024
+ if (input) {
1025
+ this.playback.seekTo(parseFloat(input.value));
1026
+ }
1027
+ }
1028
+ onPlaybackRateChange(event) {
1029
+ const select = event.target;
1030
+ if (select) {
1031
+ this.playback.setRate(parseFloat(select.value));
1032
+ }
1033
+ }
1034
+ onWordClick(word, event) {
1035
+ event.stopPropagation();
1036
+ this.playback.seekToWord(word);
1037
+ }
1038
+ onSpeakerLabelClick(event, speaker) {
1039
+ event.stopPropagation();
1040
+ this.speakerService.toggleMenu(speaker);
1041
+ }
1042
+ setSpeakerRole(speaker, role) {
1043
+ this.speakerService.setRole(speaker, role);
1044
+ }
1045
+ getSpeakerColor(speaker) {
1046
+ if (speaker === undefined)
1047
+ return 'var(--scribe-border-color)';
1048
+ const colors = [
1049
+ 'var(--scribe-speaker-0)',
1050
+ 'var(--scribe-speaker-1)',
1051
+ 'var(--scribe-speaker-2)',
1052
+ 'var(--scribe-speaker-3)',
1053
+ ];
1054
+ return colors[speaker % colors.length];
1055
+ }
1056
+ async loadPlaybackSession() {
728
1057
  const sid = this.sessionId();
729
1058
  const api = this.apiUrl();
730
1059
  if (!sid || !api)
731
1060
  return;
732
- this.isLoadingSession.set(true);
733
1061
  try {
734
- const headers = {};
735
- const token = this.token();
736
- if (token) {
737
- headers['Authorization'] = `Bearer ${token}`;
738
- }
739
- const response = await fetch(`${api}/session/${sid}`, { headers });
740
- if (!response.ok) {
741
- throw new Error(`HTTP ${response.status}`);
742
- }
743
- const data = await response.json();
744
- this.sessionData.set(data);
745
- await this.loadAudio(data.audioUrl);
1062
+ await this.playback.loadSession(api, sid, this.token());
746
1063
  }
747
1064
  catch (err) {
748
1065
  this.error.emit(err instanceof Error ? err : new Error(String(err)));
749
1066
  }
750
- finally {
751
- this.isLoadingSession.set(false);
752
- }
753
1067
  }
754
1068
  async resumeSession(sessionId) {
755
1069
  const api = this.apiUrl();
@@ -786,125 +1100,6 @@ class RecorderComponent {
786
1100
  this.isLoadingSession.set(false);
787
1101
  }
788
1102
  }
789
- async loadAudio(pcmUrl) {
790
- this.cleanupPlayback();
791
- const response = await fetch(pcmUrl);
792
- if (!response.ok) {
793
- throw new Error(`Failed to load audio: HTTP ${response.status}`);
794
- }
795
- const pcmData = await response.arrayBuffer();
796
- const wavData = pcmToWav(pcmData);
797
- this.audioBlobUrl = createAudioBlobUrl(wavData);
798
- this.audioElement = new Audio(this.audioBlobUrl);
799
- this.audioEventHandlers.loadedmetadata = () => {
800
- this.audioDuration.set(this.audioElement.duration);
801
- };
802
- this.audioEventHandlers.timeupdate = () => {
803
- this.audioCurrentTime.set(this.audioElement.currentTime);
804
- this.updateActiveSegment();
805
- };
806
- this.audioEventHandlers.ended = () => {
807
- this.isPlaying.set(false);
808
- };
809
- this.audioElement.addEventListener('loadedmetadata', this.audioEventHandlers.loadedmetadata);
810
- this.audioElement.addEventListener('timeupdate', this.audioEventHandlers.timeupdate);
811
- this.audioElement.addEventListener('ended', this.audioEventHandlers.ended);
812
- }
813
- togglePlayback() {
814
- if (!this.audioElement)
815
- return;
816
- if (this.isPlaying()) {
817
- this.audioElement.pause();
818
- this.isPlaying.set(false);
819
- }
820
- else {
821
- this.audioElement.play();
822
- this.isPlaying.set(true);
823
- }
824
- }
825
- seekTo(event) {
826
- const input = event.target;
827
- if (!input)
828
- return;
829
- const time = parseFloat(input.value);
830
- if (this.audioElement && !isNaN(time)) {
831
- this.audioElement.currentTime = time;
832
- this.audioCurrentTime.set(time);
833
- }
834
- }
835
- setPlaybackRate(event) {
836
- const select = event.target;
837
- if (!select)
838
- return;
839
- const rate = parseFloat(select.value);
840
- if (isNaN(rate))
841
- return;
842
- this.playbackRate.set(rate);
843
- if (this.audioElement) {
844
- this.audioElement.playbackRate = rate;
845
- }
846
- }
847
- seekToSegment(segment) {
848
- if (this.audioElement) {
849
- this.audioElement.currentTime = segment.startTime;
850
- this.audioCurrentTime.set(segment.startTime);
851
- if (!this.isPlaying()) {
852
- this.togglePlayback();
853
- }
854
- }
855
- }
856
- seekToWord(word, event) {
857
- event.stopPropagation();
858
- if (this.audioElement) {
859
- this.audioElement.currentTime = word.start;
860
- this.audioCurrentTime.set(word.start);
861
- if (!this.isPlaying()) {
862
- this.togglePlayback();
863
- }
864
- }
865
- }
866
- formatTimeDisplay(seconds) {
867
- return formatTime(seconds);
868
- }
869
- getSpeakerColor(speaker) {
870
- if (speaker === undefined)
871
- return 'var(--scribe-border-color)';
872
- const colors = [
873
- 'var(--scribe-speaker-0)',
874
- 'var(--scribe-speaker-1)',
875
- 'var(--scribe-speaker-2)',
876
- 'var(--scribe-speaker-3)',
877
- ];
878
- return colors[speaker % colors.length];
879
- }
880
- updateActiveSegment() {
881
- const time = this.audioCurrentTime();
882
- const segs = this.segments();
883
- const seg = segs.find((s) => time >= s.startTime && time <= s.endTime);
884
- this.activeSegmentId.set(seg?.id ?? null);
885
- if (seg?.words) {
886
- const wordIdx = seg.words.findIndex((w) => time >= w.start && time <= w.end);
887
- this.activeWordIndex.set(wordIdx >= 0 ? wordIdx : null);
888
- }
889
- else {
890
- this.activeWordIndex.set(null);
891
- }
892
- this.scrollToActiveSegment();
893
- }
894
- scrollToActiveSegment() {
895
- const activeId = this.activeSegmentId();
896
- if (!activeId)
897
- return;
898
- const container = this.elementRef.nativeElement.querySelector('.scribe-transcript-playback');
899
- const activeEl = container?.querySelector(`[data-segment-id="${activeId}"]`);
900
- if (activeEl && container) {
901
- const containerRect = container.getBoundingClientRect();
902
- const activeRect = activeEl.getBoundingClientRect();
903
- if (activeRect.top < containerRect.top || activeRect.bottom > containerRect.bottom) {
904
- activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
905
- }
906
- }
907
- }
908
1103
  async startRecording() {
909
1104
  try {
910
1105
  await this.socket.connect(this.wsUrl(), this.token(), this.premium());
@@ -954,78 +1149,83 @@ class RecorderComponent {
954
1149
  this.currentSpeaker.set(event.speaker);
955
1150
  }
956
1151
  }
1152
+ async triggerIdentification() {
1153
+ const api = this.apiUrl() || this.lambdaUrl();
1154
+ const sessionId = this.socket.getSessionId() || this.lastSessionId();
1155
+ await this.speakerService.identify(this.entries(), api, this.token(), sessionId, this.premium());
1156
+ }
957
1157
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: RecorderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
958
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.18", type: RecorderComponent, isStandalone: true, selector: "ngx-jaimes-scribe-recorder", inputs: { wsUrl: { classPropertyName: "wsUrl", publicName: "wsUrl", isSignal: true, isRequired: false, transformFunction: null }, token: { classPropertyName: "token", publicName: "token", isSignal: true, isRequired: false, transformFunction: null }, speakerLabels: { classPropertyName: "speakerLabels", publicName: "speakerLabels", isSignal: true, isRequired: false, transformFunction: null }, premium: { classPropertyName: "premium", publicName: "premium", isSignal: true, isRequired: false, transformFunction: null }, templates: { classPropertyName: "templates", publicName: "templates", isSignal: true, isRequired: false, transformFunction: null }, lambdaUrl: { classPropertyName: "lambdaUrl", publicName: "lambdaUrl", isSignal: true, isRequired: false, transformFunction: null }, sessionId: { classPropertyName: "sessionId", publicName: "sessionId", isSignal: true, isRequired: false, transformFunction: null }, apiUrl: { classPropertyName: "apiUrl", publicName: "apiUrl", isSignal: true, isRequired: false, transformFunction: null }, resumeSessionId: { classPropertyName: "resumeSessionId", publicName: "resumeSessionId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { sessionStarted: "sessionStarted", sessionFinished: "sessionFinished", error: "error", documentGenerated: "documentGenerated", generationError: "generationError" }, host: { listeners: { "document:click": "onDocumentClick($event)" } }, ngImport: i0, template: `
1158
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.18", type: RecorderComponent, isStandalone: true, selector: "ngx-jaimes-scribe-recorder", inputs: { wsUrl: { classPropertyName: "wsUrl", publicName: "wsUrl", isSignal: true, isRequired: false, transformFunction: null }, token: { classPropertyName: "token", publicName: "token", isSignal: true, isRequired: false, transformFunction: null }, speakerLabels: { classPropertyName: "speakerLabels", publicName: "speakerLabels", isSignal: true, isRequired: false, transformFunction: null }, premium: { classPropertyName: "premium", publicName: "premium", isSignal: true, isRequired: false, transformFunction: null }, templates: { classPropertyName: "templates", publicName: "templates", isSignal: true, isRequired: false, transformFunction: null }, lambdaUrl: { classPropertyName: "lambdaUrl", publicName: "lambdaUrl", isSignal: true, isRequired: false, transformFunction: null }, sessionId: { classPropertyName: "sessionId", publicName: "sessionId", isSignal: true, isRequired: false, transformFunction: null }, apiUrl: { classPropertyName: "apiUrl", publicName: "apiUrl", isSignal: true, isRequired: false, transformFunction: null }, resumeSessionId: { classPropertyName: "resumeSessionId", publicName: "resumeSessionId", isSignal: true, isRequired: false, transformFunction: null }, doctorName: { classPropertyName: "doctorName", publicName: "doctorName", isSignal: true, isRequired: false, transformFunction: null }, patientName: { classPropertyName: "patientName", publicName: "patientName", isSignal: true, isRequired: false, transformFunction: null }, companionName: { classPropertyName: "companionName", publicName: "companionName", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { sessionStarted: "sessionStarted", sessionFinished: "sessionFinished", error: "error", documentGenerated: "documentGenerated", generationError: "generationError" }, host: { listeners: { "document:click": "onDocumentClick($event)" } }, providers: [SpeakerIdentificationService, PlaybackService], ngImport: i0, template: `
959
1159
  <div class="scribe-recorder">
960
1160
  @if (mode() === 'playback') {
961
1161
  <!-- Playback Mode -->
962
1162
  <div class="scribe-playback">
963
- @if (isLoadingSession()) {
1163
+ @if (playback.isLoading()) {
964
1164
  <div class="scribe-loading">
965
1165
  <svg class="scribe-icon scribe-icon--spinner" viewBox="0 0 24 24" width="24" height="24">
966
1166
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"/>
967
1167
  </svg>
968
1168
  <span>Carregando sessão...</span>
969
1169
  </div>
970
- } @else if (sessionData()) {
1170
+ } @else if (playback.sessionData()) {
971
1171
  <!-- Audio Player -->
972
1172
  <div class="scribe-player">
973
- <div class="scribe-player-row">
974
- <button
975
- class="scribe-btn scribe-btn--play"
976
- (click)="togglePlayback()"
977
- [attr.aria-label]="isPlaying() ? 'Pausar' : 'Reproduzir'"
978
- >
979
- @if (isPlaying()) {
980
- <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
981
- <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
982
- </svg>
983
- } @else {
984
- <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
985
- <path d="M8 5v14l11-7z"/>
986
- </svg>
987
- }
988
- </button>
1173
+ <button
1174
+ class="scribe-btn scribe-btn--play"
1175
+ (click)="playback.togglePlayback()"
1176
+ [attr.aria-label]="playback.isPlaying() ? 'Pausar' : 'Reproduzir'"
1177
+ >
1178
+ @if (playback.isPlaying()) {
1179
+ <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1180
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1181
+ </svg>
1182
+ } @else {
1183
+ <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1184
+ <path d="M8 5v14l11-7z"/>
1185
+ </svg>
1186
+ }
1187
+ </button>
989
1188
 
1189
+ <div class="scribe-timeline">
990
1190
  <input
991
1191
  type="range"
992
1192
  class="scribe-timeline-slider"
993
1193
  [min]="0"
994
- [max]="audioDuration()"
995
- [value]="audioCurrentTime()"
1194
+ [max]="playback.duration()"
1195
+ [value]="playback.currentTime()"
996
1196
  step="0.1"
997
- (input)="seekTo($event)"
1197
+ (input)="onSeek($event)"
998
1198
  aria-label="Posição do áudio"
999
1199
  />
1000
-
1001
- <select
1002
- class="scribe-speed"
1003
- [value]="playbackRate()"
1004
- (change)="setPlaybackRate($event)"
1005
- aria-label="Velocidade de reprodução"
1006
- >
1007
- <option value="0.5">0.5x</option>
1008
- <option value="0.75">0.75x</option>
1009
- <option value="1">1x</option>
1010
- <option value="1.25">1.25x</option>
1011
- <option value="1.5">1.5x</option>
1012
- <option value="2">2x</option>
1013
- </select>
1014
- </div>
1015
- <div class="scribe-time">
1016
- {{ formatTimeDisplay(audioCurrentTime()) }} / {{ formatTimeDisplay(audioDuration()) }}
1200
+ <div class="scribe-time">
1201
+ {{ playback.formatTime(playback.currentTime()) }} / {{ playback.formatTime(playback.duration()) }}
1202
+ </div>
1017
1203
  </div>
1204
+
1205
+ <select
1206
+ class="scribe-speed"
1207
+ [value]="playback.playbackRate()"
1208
+ (change)="onPlaybackRateChange($event)"
1209
+ aria-label="Velocidade de reprodução"
1210
+ >
1211
+ <option value="0.5">0.5x</option>
1212
+ <option value="0.75">0.75x</option>
1213
+ <option value="1">1x</option>
1214
+ <option value="1.25">1.25x</option>
1215
+ <option value="1.5">1.5x</option>
1216
+ <option value="2">2x</option>
1217
+ </select>
1018
1218
  </div>
1019
1219
 
1020
1220
  <!-- Transcription with Highlight -->
1021
1221
  <div class="scribe-transcript-playback">
1022
- @for (segment of segments(); track segment.id; let idx = $index) {
1222
+ @for (segment of playback.segments; track segment.id; let idx = $index) {
1023
1223
  <div
1024
1224
  class="scribe-segment"
1025
- [class.scribe-segment--active]="activeSegmentId() === segment.id"
1225
+ [class.scribe-segment--active]="playback.activeSegmentId() === segment.id"
1026
1226
  [style.--speaker-color]="getSpeakerColor(segment.speaker)"
1027
1227
  [attr.data-segment-id]="segment.id"
1028
- (click)="seekToSegment(segment)"
1228
+ (click)="playback.seekToSegment(segment)"
1029
1229
  >
1030
1230
  @if (segment.speaker !== undefined && shouldShowSpeakerLabel(idx)) {
1031
1231
  <span class="scribe-speaker-label" [class]="'scribe-speaker--' + segment.speaker">
@@ -1038,8 +1238,8 @@ class RecorderComponent {
1038
1238
  @for (word of segment.words; track $index; let i = $index) {
1039
1239
  <span
1040
1240
  class="scribe-word"
1041
- [class.scribe-word--active]="activeSegmentId() === segment.id && activeWordIndex() === i"
1042
- (click)="seekToWord(word, $event)"
1241
+ [class.scribe-word--active]="playback.activeSegmentId() === segment.id && playback.activeWordIndex() === i"
1242
+ (click)="onWordClick(word, $event)"
1043
1243
  >{{ word.word }}</span>{{ ' ' }}
1044
1244
  }
1045
1245
  } @else {
@@ -1048,7 +1248,7 @@ class RecorderComponent {
1048
1248
  </p>
1049
1249
  </div>
1050
1250
  }
1051
- @if (segments().length === 0) {
1251
+ @if (playback.segments.length === 0) {
1052
1252
  <span class="scribe-placeholder">Nenhuma transcrição disponível.</span>
1053
1253
  }
1054
1254
  </div>
@@ -1084,17 +1284,14 @@ class RecorderComponent {
1084
1284
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"/>
1085
1285
  </svg>
1086
1286
  } @else if (isPaused()) {
1087
- <!-- Play icon when paused -->
1088
1287
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1089
1288
  <path d="M8 5v14l11-7z"/>
1090
1289
  </svg>
1091
1290
  } @else if (isRecording()) {
1092
- <!-- Pause icon when recording -->
1093
1291
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1094
1292
  <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1095
1293
  </svg>
1096
1294
  } @else {
1097
- <!-- Mic icon when idle -->
1098
1295
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1099
1296
  <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/>
1100
1297
  <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
@@ -1169,8 +1366,39 @@ class RecorderComponent {
1169
1366
  @for (group of speakerGroups(); track $index) {
1170
1367
  <div class="scribe-speaker-block" [attr.data-speaker]="group.speaker">
1171
1368
  @if (group.speaker !== undefined && group.speaker !== null) {
1172
- <span class="scribe-speaker-label" [class]="'scribe-speaker--' + group.speaker">
1173
- {{ getSpeakerLabel(group.speaker) }}
1369
+ <span class="scribe-speaker-label-wrapper">
1370
+ <button
1371
+ type="button"
1372
+ class="scribe-speaker-label scribe-speaker-label--editable"
1373
+ [class]="'scribe-speaker--' + group.speaker"
1374
+ [class.scribe-speaker-label--low-confidence]="speakerService.hasLowConfidence(group.speaker)"
1375
+ (click)="onSpeakerLabelClick($event, group.speaker)"
1376
+ >
1377
+ {{ getSpeakerLabel(group.speaker) }}
1378
+ <svg class="scribe-chevron" viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
1379
+ <path d="M7 10l5 5 5-5z"/>
1380
+ </svg>
1381
+ @if (speakerService.hasLowConfidence(group.speaker)) {
1382
+ <span class="scribe-warning" title="Confiança baixa - verifique">⚠️</span>
1383
+ }
1384
+ </button>
1385
+ @if (speakerService.editingSpeaker() === group.speaker) {
1386
+ <div class="scribe-speaker-dropdown">
1387
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'doctor')">
1388
+ <span class="scribe-dropdown-icon">🩺</span> Médico
1389
+ </button>
1390
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'patient')">
1391
+ <span class="scribe-dropdown-icon">🙋</span> Paciente
1392
+ </button>
1393
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'companion')">
1394
+ <span class="scribe-dropdown-icon">👥</span> Acompanhante
1395
+ </button>
1396
+ <div class="scribe-dropdown-divider"></div>
1397
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'other')">
1398
+ <span class="scribe-dropdown-icon">✏️</span> Outro
1399
+ </button>
1400
+ </div>
1401
+ }
1174
1402
  </span>
1175
1403
  }
1176
1404
  <div class="scribe-speaker-text">
@@ -1197,81 +1425,81 @@ class RecorderComponent {
1197
1425
  }
1198
1426
  }
1199
1427
  </div>
1200
- `, isInline: true, styles: [":host{--scribe-primary: #4caf50;--scribe-primary-dark: #388e3c;--scribe-primary-light: #81c784;--scribe-danger: #f44336;--scribe-danger-dark: #d32f2f;--scribe-text-color: #212121;--scribe-text-partial: #9e9e9e;--scribe-font-family: inherit;--scribe-font-size: 1rem;--scribe-bg: #ffffff;--scribe-bg-transcript: #f5f5f5;--scribe-border-radius: 8px;--scribe-border-color: #e0e0e0;--scribe-level-bg: #e0e0e0;--scribe-level-fill: #4caf50;--scribe-speaker-0: #2196f3;--scribe-speaker-1: #9c27b0;--scribe-speaker-2: #ff9800;--scribe-speaker-3: #009688;display:block;font-family:var(--scribe-font-family);font-size:var(--scribe-font-size)}.scribe-recorder{background:var(--scribe-bg);border-radius:var(--scribe-border-radius)}.scribe-controls{display:flex;align-items:center;gap:1rem;padding:1rem}.scribe-btn{display:flex;align-items:center;justify-content:center;gap:.5rem;padding:.75rem;border:none;border-radius:var(--scribe-border-radius);font-family:var(--scribe-font-family);font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}.scribe-btn:disabled{opacity:.5;cursor:not-allowed}.scribe-btn--mic{width:48px;height:48px;border-radius:50%;background:var(--scribe-bg-transcript);color:var(--scribe-text-color)}.scribe-btn--mic:hover:not(:disabled){background:var(--scribe-border-color)}.scribe-btn--mic.scribe-btn--active{background:var(--scribe-danger);color:#fff;animation:pulse-recording 1.5s ease-in-out infinite}.scribe-btn--mic.scribe-btn--paused{background:var(--scribe-primary);color:#fff}.scribe-btn--mic.scribe-btn--paused:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-btn--mic.scribe-btn--connecting{background:var(--scribe-primary-light);color:#fff}.scribe-btn--finish{padding:.75rem 1.25rem;background:var(--scribe-primary);color:#fff}.scribe-btn--finish:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-icon{flex-shrink:0}.scribe-icon--spinner{animation:spin 1s linear infinite}.scribe-level{flex:1;height:8px;background:var(--scribe-level-bg);border-radius:4px;overflow:hidden;opacity:.5;transition:opacity .2s ease}.scribe-level--active{opacity:1}.scribe-level__fill{height:100%;background:var(--scribe-level-fill);border-radius:4px;transition:width .05s ease-out}.scribe-transcript{min-height:120px;max-height:400px;overflow-y:auto;padding:1rem;margin:0 1rem 1rem;background:var(--scribe-bg-transcript);border-radius:var(--scribe-border-radius);line-height:1.6}.scribe-transcript--empty{display:flex;align-items:center;justify-content:center}.scribe-placeholder{color:var(--scribe-text-partial);font-style:italic}.scribe-speaker-block{margin-bottom:.75rem;padding-left:.5rem;border-left:3px solid var(--scribe-border-color)}.scribe-speaker-block--partial{opacity:.7}.scribe-speaker-block[data-speaker=\"0\"]{border-left-color:var(--scribe-speaker-0)}.scribe-speaker-block[data-speaker=\"1\"]{border-left-color:var(--scribe-speaker-1)}.scribe-speaker-block[data-speaker=\"2\"]{border-left-color:var(--scribe-speaker-2)}.scribe-speaker-block[data-speaker=\"3\"]{border-left-color:var(--scribe-speaker-3)}.scribe-speaker-label{display:inline-block;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;padding:.125rem .5rem;border-radius:4px;margin-bottom:.25rem;color:#fff;background:var(--scribe-border-color)}.scribe-speaker--0{background:var(--scribe-speaker-0)}.scribe-speaker--1{background:var(--scribe-speaker-1)}.scribe-speaker--2{background:var(--scribe-speaker-2)}.scribe-speaker--3{background:var(--scribe-speaker-3)}.scribe-speaker-text{margin-top:.25rem}.scribe-text{word-wrap:break-word}.scribe-text--final{color:var(--scribe-text-color)}.scribe-text--partial{color:var(--scribe-text-partial);font-style:italic}.scribe-generate-container{position:relative;margin-left:.5rem}.scribe-btn--generate{display:flex;align-items:center;gap:.5rem;padding:.75rem 1.25rem;background:var(--scribe-primary);color:#fff;border:none;border-radius:var(--scribe-border-radius);font-family:var(--scribe-font-family);font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}.scribe-btn--generate:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-btn--generate:disabled{opacity:.7;cursor:not-allowed}.scribe-template-menu{position:absolute;top:100%;left:0;margin-top:.25rem;min-width:220px;background:var(--scribe-bg);border:1px solid var(--scribe-border-color);border-radius:var(--scribe-border-radius);box-shadow:0 4px 12px #00000026;z-index:100;overflow:hidden}.scribe-template-item{display:flex;flex-direction:column;align-items:flex-start;width:100%;padding:.75rem 1rem;border:none;background:transparent;cursor:pointer;text-align:left;transition:background .15s ease}.scribe-template-item:hover{background:var(--scribe-bg-transcript)}.scribe-template-item:not(:last-child){border-bottom:1px solid var(--scribe-border-color)}.scribe-template-name{font-weight:500;color:var(--scribe-text-color)}.scribe-template-desc{font-size:.75rem;color:var(--scribe-text-partial);margin-top:.25rem}@keyframes pulse-recording{0%,to{box-shadow:0 0 #f4433666}50%{box-shadow:0 0 0 8px #f4433600}}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.scribe-playback{padding:1rem}.scribe-loading,.scribe-error{display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--scribe-text-partial)}.scribe-player{display:flex;flex-direction:column;gap:.5rem;padding:1rem;background:var(--scribe-bg-transcript);border-radius:var(--scribe-border-radius);margin-bottom:1rem}.scribe-player-row{display:flex;align-items:center;gap:1rem}.scribe-btn--play{width:48px;height:48px;border-radius:50%;background:var(--scribe-primary);color:#fff;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .2s ease;flex-shrink:0}.scribe-btn--play:hover{background:var(--scribe-primary-dark)}.scribe-timeline-slider{flex:1;cursor:pointer;height:6px;-webkit-appearance:none;appearance:none;background:var(--scribe-level-bg);border-radius:3px;outline:none}.scribe-timeline-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;background:var(--scribe-primary);border-radius:50%;cursor:pointer}.scribe-timeline-slider::-moz-range-thumb{width:14px;height:14px;background:var(--scribe-primary);border-radius:50%;cursor:pointer;border:none}.scribe-time{font-size:.75rem;color:var(--scribe-text-partial);text-align:center}.scribe-speed{padding:.375rem .5rem;border-radius:4px;border:1px solid var(--scribe-border-color);background:var(--scribe-bg);font-size:.875rem;cursor:pointer}.scribe-transcript-playback{max-height:400px;overflow-y:auto;scroll-behavior:smooth;padding:.5rem}.scribe-segment{padding:.75rem 1rem;margin-bottom:.5rem;border-left:3px solid var(--speaker-color, var(--scribe-border-color));background:var(--scribe-bg);border-radius:0 var(--scribe-border-radius) var(--scribe-border-radius) 0;cursor:pointer;transition:background .2s ease}.scribe-segment:hover{background:var(--scribe-bg-transcript)}.scribe-segment--active{background:#4caf501a;border-left-color:var(--scribe-primary)}.scribe-segment-text{margin:.25rem 0 0;line-height:1.6}.scribe-word{transition:background .15s ease,color .15s ease;padding:0 1px;border-radius:2px}.scribe-word:hover{background:#0000000d;cursor:pointer}.scribe-word--active{background:var(--scribe-primary);color:#fff}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] });
1428
+ `, isInline: true, styles: [":host{--scribe-primary: #4caf50;--scribe-primary-dark: #388e3c;--scribe-primary-light: #81c784;--scribe-danger: #f44336;--scribe-danger-dark: #d32f2f;--scribe-text-color: #212121;--scribe-text-partial: #9e9e9e;--scribe-font-family: inherit;--scribe-font-size: 1rem;--scribe-bg: #ffffff;--scribe-bg-transcript: #f5f5f5;--scribe-border-radius: 8px;--scribe-border-color: #e0e0e0;--scribe-level-bg: #e0e0e0;--scribe-level-fill: #4caf50;--scribe-speaker-0: #2196f3;--scribe-speaker-1: #9c27b0;--scribe-speaker-2: #ff9800;--scribe-speaker-3: #009688;display:block;font-family:var(--scribe-font-family);font-size:var(--scribe-font-size)}.scribe-recorder{background:var(--scribe-bg);border-radius:var(--scribe-border-radius)}.scribe-controls{display:flex;align-items:center;gap:1rem;padding:1rem}.scribe-btn{display:flex;align-items:center;justify-content:center;gap:.5rem;padding:.75rem;border:none;border-radius:var(--scribe-border-radius);font-family:var(--scribe-font-family);font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}.scribe-btn:disabled{opacity:.5;cursor:not-allowed}.scribe-btn--mic{width:48px;height:48px;border-radius:50%;background:var(--scribe-bg-transcript);color:var(--scribe-text-color)}.scribe-btn--mic:hover:not(:disabled){background:var(--scribe-border-color)}.scribe-btn--mic.scribe-btn--active{background:var(--scribe-danger);color:#fff;animation:pulse-recording 1.5s ease-in-out infinite}.scribe-btn--mic.scribe-btn--paused{background:var(--scribe-primary);color:#fff}.scribe-btn--mic.scribe-btn--paused:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-btn--mic.scribe-btn--connecting{background:var(--scribe-primary-light);color:#fff}.scribe-btn--finish{padding:.75rem 1.25rem;background:var(--scribe-primary);color:#fff}.scribe-btn--finish:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-icon{flex-shrink:0}.scribe-icon--spinner{animation:spin 1s linear infinite}.scribe-level{flex:1;height:8px;background:var(--scribe-level-bg);border-radius:4px;overflow:hidden;opacity:.5;transition:opacity .2s ease}.scribe-level--active{opacity:1}.scribe-level__fill{height:100%;background:var(--scribe-level-fill);border-radius:4px;transition:width .05s ease-out}.scribe-transcript{min-height:120px;max-height:400px;overflow-y:auto;padding:1rem;margin:0 1rem 1rem;background:var(--scribe-bg-transcript);border-radius:var(--scribe-border-radius);line-height:1.6}.scribe-transcript--empty{display:flex;align-items:center;justify-content:center}.scribe-placeholder{color:var(--scribe-text-partial);font-style:italic}.scribe-speaker-block{margin-bottom:.75rem;padding-left:.5rem;border-left:3px solid var(--scribe-border-color)}.scribe-speaker-block--partial{opacity:.7}.scribe-speaker-block[data-speaker=\"0\"]{border-left-color:var(--scribe-speaker-0)}.scribe-speaker-block[data-speaker=\"1\"]{border-left-color:var(--scribe-speaker-1)}.scribe-speaker-block[data-speaker=\"2\"]{border-left-color:var(--scribe-speaker-2)}.scribe-speaker-block[data-speaker=\"3\"]{border-left-color:var(--scribe-speaker-3)}.scribe-speaker-label{display:inline-block;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;padding:.125rem .5rem;border-radius:4px;margin-bottom:.25rem;color:#fff;background:var(--scribe-border-color)}.scribe-speaker--0{background:var(--scribe-speaker-0)}.scribe-speaker--1{background:var(--scribe-speaker-1)}.scribe-speaker--2{background:var(--scribe-speaker-2)}.scribe-speaker--3{background:var(--scribe-speaker-3)}.scribe-speaker-text{margin-top:.25rem}.scribe-text{word-wrap:break-word}.scribe-text--final{color:var(--scribe-text-color)}.scribe-text--partial{color:var(--scribe-text-partial);font-style:italic}.scribe-generate-container{position:relative;margin-left:.5rem}.scribe-btn--generate{display:flex;align-items:center;gap:.5rem;padding:.75rem 1.25rem;background:var(--scribe-primary);color:#fff;border:none;border-radius:var(--scribe-border-radius);font-family:var(--scribe-font-family);font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}.scribe-btn--generate:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-btn--generate:disabled{opacity:.7;cursor:not-allowed}.scribe-template-menu{position:absolute;top:100%;left:0;margin-top:.25rem;min-width:220px;background:var(--scribe-bg);border:1px solid var(--scribe-border-color);border-radius:var(--scribe-border-radius);box-shadow:0 4px 12px #00000026;z-index:100;overflow:hidden}.scribe-template-item{display:flex;flex-direction:column;align-items:flex-start;width:100%;padding:.75rem 1rem;border:none;background:transparent;cursor:pointer;text-align:left;transition:background .15s ease}.scribe-template-item:hover{background:var(--scribe-bg-transcript)}.scribe-template-item:not(:last-child){border-bottom:1px solid var(--scribe-border-color)}.scribe-template-name{font-weight:500;color:var(--scribe-text-color)}.scribe-template-desc{font-size:.75rem;color:var(--scribe-text-partial);margin-top:.25rem}@keyframes pulse-recording{0%,to{box-shadow:0 0 #f4433666}50%{box-shadow:0 0 0 8px #f4433600}}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.scribe-playback{padding:1rem}.scribe-loading,.scribe-error{display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--scribe-text-partial)}.scribe-player{display:flex;align-items:center;gap:1rem;padding:1rem;background:var(--scribe-bg-transcript);border-radius:var(--scribe-border-radius);margin-bottom:1rem}.scribe-btn--play{width:48px;height:48px;border-radius:50%;background:var(--scribe-primary);color:#fff;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .2s ease;flex-shrink:0}.scribe-btn--play:hover{background:var(--scribe-primary-dark)}.scribe-timeline{flex:1;position:relative}.scribe-timeline-slider{width:100%;cursor:pointer;-webkit-appearance:none;appearance:none;background:var(--scribe-level-bg);border-radius:3px;outline:none}.scribe-timeline-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;background:var(--scribe-primary);border-radius:50%;cursor:pointer}.scribe-timeline-slider::-moz-range-thumb{width:14px;height:14px;background:var(--scribe-primary);border-radius:50%;cursor:pointer;border:none}.scribe-time{position:absolute;top:20px;left:50%;transform:translate(-50%);font-size:.75rem;color:var(--scribe-text-partial);white-space:nowrap}.scribe-speed{padding:.375rem .5rem;border-radius:4px;border:1px solid var(--scribe-border-color);background:var(--scribe-bg);font-size:.875rem;cursor:pointer}.scribe-transcript-playback{max-height:400px;overflow-y:auto;scroll-behavior:smooth;padding:.5rem}.scribe-segment{padding:.75rem 1rem;margin-bottom:.5rem;border-left:3px solid var(--speaker-color, var(--scribe-border-color));background:var(--scribe-bg);border-radius:0 var(--scribe-border-radius) var(--scribe-border-radius) 0;cursor:pointer;transition:background .2s ease}.scribe-segment:hover{background:var(--scribe-bg-transcript)}.scribe-segment--active{background:#4caf501a;border-left-color:var(--scribe-primary)}.scribe-segment-text{margin:.25rem 0 0;line-height:1.6}.scribe-word{transition:background .15s ease,color .15s ease;padding:0 1px;border-radius:2px}.scribe-word:hover{background:#0000000d;cursor:pointer}.scribe-word--active{background:var(--scribe-primary);color:#fff}.scribe-speaker-label-wrapper{position:relative;display:inline-block}.scribe-speaker-label--editable{cursor:pointer;display:inline-flex;align-items:center;gap:4px;border:1px solid transparent;transition:all .15s ease}.scribe-speaker-label--editable:hover{border-color:#ffffff4d}.scribe-speaker-label--low-confidence{border-color:var(--scribe-warning, #f59e0b)!important}.scribe-chevron{opacity:.7}.scribe-warning{font-size:.875rem;margin-left:2px}.scribe-speaker-dropdown{position:absolute;top:100%;left:0;z-index:100;min-width:140px;margin-top:4px;background:var(--scribe-bg);border:1px solid var(--scribe-border-color);border-radius:6px;box-shadow:0 4px 12px #00000026;padding:4px}.scribe-dropdown-item{display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;border:none;background:none;cursor:pointer;font-family:var(--scribe-font-family);font-size:.875rem;text-align:left;border-radius:4px;color:var(--scribe-text-color)}.scribe-dropdown-item:hover{background:var(--scribe-bg-transcript)}.scribe-dropdown-icon{font-size:1rem}.scribe-dropdown-divider{height:1px;background:var(--scribe-border-color);margin:4px 0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] });
1201
1429
  }
1202
1430
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: RecorderComponent, decorators: [{
1203
1431
  type: Component,
1204
- args: [{ selector: 'ngx-jaimes-scribe-recorder', standalone: true, imports: [CommonModule], template: `
1432
+ args: [{ selector: 'ngx-jaimes-scribe-recorder', standalone: true, imports: [CommonModule], providers: [SpeakerIdentificationService, PlaybackService], template: `
1205
1433
  <div class="scribe-recorder">
1206
1434
  @if (mode() === 'playback') {
1207
1435
  <!-- Playback Mode -->
1208
1436
  <div class="scribe-playback">
1209
- @if (isLoadingSession()) {
1437
+ @if (playback.isLoading()) {
1210
1438
  <div class="scribe-loading">
1211
1439
  <svg class="scribe-icon scribe-icon--spinner" viewBox="0 0 24 24" width="24" height="24">
1212
1440
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"/>
1213
1441
  </svg>
1214
1442
  <span>Carregando sessão...</span>
1215
1443
  </div>
1216
- } @else if (sessionData()) {
1444
+ } @else if (playback.sessionData()) {
1217
1445
  <!-- Audio Player -->
1218
1446
  <div class="scribe-player">
1219
- <div class="scribe-player-row">
1220
- <button
1221
- class="scribe-btn scribe-btn--play"
1222
- (click)="togglePlayback()"
1223
- [attr.aria-label]="isPlaying() ? 'Pausar' : 'Reproduzir'"
1224
- >
1225
- @if (isPlaying()) {
1226
- <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1227
- <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1228
- </svg>
1229
- } @else {
1230
- <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1231
- <path d="M8 5v14l11-7z"/>
1232
- </svg>
1233
- }
1234
- </button>
1447
+ <button
1448
+ class="scribe-btn scribe-btn--play"
1449
+ (click)="playback.togglePlayback()"
1450
+ [attr.aria-label]="playback.isPlaying() ? 'Pausar' : 'Reproduzir'"
1451
+ >
1452
+ @if (playback.isPlaying()) {
1453
+ <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1454
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1455
+ </svg>
1456
+ } @else {
1457
+ <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1458
+ <path d="M8 5v14l11-7z"/>
1459
+ </svg>
1460
+ }
1461
+ </button>
1235
1462
 
1463
+ <div class="scribe-timeline">
1236
1464
  <input
1237
1465
  type="range"
1238
1466
  class="scribe-timeline-slider"
1239
1467
  [min]="0"
1240
- [max]="audioDuration()"
1241
- [value]="audioCurrentTime()"
1468
+ [max]="playback.duration()"
1469
+ [value]="playback.currentTime()"
1242
1470
  step="0.1"
1243
- (input)="seekTo($event)"
1471
+ (input)="onSeek($event)"
1244
1472
  aria-label="Posição do áudio"
1245
1473
  />
1246
-
1247
- <select
1248
- class="scribe-speed"
1249
- [value]="playbackRate()"
1250
- (change)="setPlaybackRate($event)"
1251
- aria-label="Velocidade de reprodução"
1252
- >
1253
- <option value="0.5">0.5x</option>
1254
- <option value="0.75">0.75x</option>
1255
- <option value="1">1x</option>
1256
- <option value="1.25">1.25x</option>
1257
- <option value="1.5">1.5x</option>
1258
- <option value="2">2x</option>
1259
- </select>
1260
- </div>
1261
- <div class="scribe-time">
1262
- {{ formatTimeDisplay(audioCurrentTime()) }} / {{ formatTimeDisplay(audioDuration()) }}
1474
+ <div class="scribe-time">
1475
+ {{ playback.formatTime(playback.currentTime()) }} / {{ playback.formatTime(playback.duration()) }}
1476
+ </div>
1263
1477
  </div>
1478
+
1479
+ <select
1480
+ class="scribe-speed"
1481
+ [value]="playback.playbackRate()"
1482
+ (change)="onPlaybackRateChange($event)"
1483
+ aria-label="Velocidade de reprodução"
1484
+ >
1485
+ <option value="0.5">0.5x</option>
1486
+ <option value="0.75">0.75x</option>
1487
+ <option value="1">1x</option>
1488
+ <option value="1.25">1.25x</option>
1489
+ <option value="1.5">1.5x</option>
1490
+ <option value="2">2x</option>
1491
+ </select>
1264
1492
  </div>
1265
1493
 
1266
1494
  <!-- Transcription with Highlight -->
1267
1495
  <div class="scribe-transcript-playback">
1268
- @for (segment of segments(); track segment.id; let idx = $index) {
1496
+ @for (segment of playback.segments; track segment.id; let idx = $index) {
1269
1497
  <div
1270
1498
  class="scribe-segment"
1271
- [class.scribe-segment--active]="activeSegmentId() === segment.id"
1499
+ [class.scribe-segment--active]="playback.activeSegmentId() === segment.id"
1272
1500
  [style.--speaker-color]="getSpeakerColor(segment.speaker)"
1273
1501
  [attr.data-segment-id]="segment.id"
1274
- (click)="seekToSegment(segment)"
1502
+ (click)="playback.seekToSegment(segment)"
1275
1503
  >
1276
1504
  @if (segment.speaker !== undefined && shouldShowSpeakerLabel(idx)) {
1277
1505
  <span class="scribe-speaker-label" [class]="'scribe-speaker--' + segment.speaker">
@@ -1284,8 +1512,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1284
1512
  @for (word of segment.words; track $index; let i = $index) {
1285
1513
  <span
1286
1514
  class="scribe-word"
1287
- [class.scribe-word--active]="activeSegmentId() === segment.id && activeWordIndex() === i"
1288
- (click)="seekToWord(word, $event)"
1515
+ [class.scribe-word--active]="playback.activeSegmentId() === segment.id && playback.activeWordIndex() === i"
1516
+ (click)="onWordClick(word, $event)"
1289
1517
  >{{ word.word }}</span>{{ ' ' }}
1290
1518
  }
1291
1519
  } @else {
@@ -1294,7 +1522,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1294
1522
  </p>
1295
1523
  </div>
1296
1524
  }
1297
- @if (segments().length === 0) {
1525
+ @if (playback.segments.length === 0) {
1298
1526
  <span class="scribe-placeholder">Nenhuma transcrição disponível.</span>
1299
1527
  }
1300
1528
  </div>
@@ -1330,17 +1558,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1330
1558
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"/>
1331
1559
  </svg>
1332
1560
  } @else if (isPaused()) {
1333
- <!-- Play icon when paused -->
1334
1561
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1335
1562
  <path d="M8 5v14l11-7z"/>
1336
1563
  </svg>
1337
1564
  } @else if (isRecording()) {
1338
- <!-- Pause icon when recording -->
1339
1565
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1340
1566
  <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1341
1567
  </svg>
1342
1568
  } @else {
1343
- <!-- Mic icon when idle -->
1344
1569
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1345
1570
  <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/>
1346
1571
  <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
@@ -1415,8 +1640,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1415
1640
  @for (group of speakerGroups(); track $index) {
1416
1641
  <div class="scribe-speaker-block" [attr.data-speaker]="group.speaker">
1417
1642
  @if (group.speaker !== undefined && group.speaker !== null) {
1418
- <span class="scribe-speaker-label" [class]="'scribe-speaker--' + group.speaker">
1419
- {{ getSpeakerLabel(group.speaker) }}
1643
+ <span class="scribe-speaker-label-wrapper">
1644
+ <button
1645
+ type="button"
1646
+ class="scribe-speaker-label scribe-speaker-label--editable"
1647
+ [class]="'scribe-speaker--' + group.speaker"
1648
+ [class.scribe-speaker-label--low-confidence]="speakerService.hasLowConfidence(group.speaker)"
1649
+ (click)="onSpeakerLabelClick($event, group.speaker)"
1650
+ >
1651
+ {{ getSpeakerLabel(group.speaker) }}
1652
+ <svg class="scribe-chevron" viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
1653
+ <path d="M7 10l5 5 5-5z"/>
1654
+ </svg>
1655
+ @if (speakerService.hasLowConfidence(group.speaker)) {
1656
+ <span class="scribe-warning" title="Confiança baixa - verifique">⚠️</span>
1657
+ }
1658
+ </button>
1659
+ @if (speakerService.editingSpeaker() === group.speaker) {
1660
+ <div class="scribe-speaker-dropdown">
1661
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'doctor')">
1662
+ <span class="scribe-dropdown-icon">🩺</span> Médico
1663
+ </button>
1664
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'patient')">
1665
+ <span class="scribe-dropdown-icon">🙋</span> Paciente
1666
+ </button>
1667
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'companion')">
1668
+ <span class="scribe-dropdown-icon">👥</span> Acompanhante
1669
+ </button>
1670
+ <div class="scribe-dropdown-divider"></div>
1671
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'other')">
1672
+ <span class="scribe-dropdown-icon">✏️</span> Outro
1673
+ </button>
1674
+ </div>
1675
+ }
1420
1676
  </span>
1421
1677
  }
1422
1678
  <div class="scribe-speaker-text">
@@ -1443,7 +1699,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1443
1699
  }
1444
1700
  }
1445
1701
  </div>
1446
- `, styles: [":host{--scribe-primary: #4caf50;--scribe-primary-dark: #388e3c;--scribe-primary-light: #81c784;--scribe-danger: #f44336;--scribe-danger-dark: #d32f2f;--scribe-text-color: #212121;--scribe-text-partial: #9e9e9e;--scribe-font-family: inherit;--scribe-font-size: 1rem;--scribe-bg: #ffffff;--scribe-bg-transcript: #f5f5f5;--scribe-border-radius: 8px;--scribe-border-color: #e0e0e0;--scribe-level-bg: #e0e0e0;--scribe-level-fill: #4caf50;--scribe-speaker-0: #2196f3;--scribe-speaker-1: #9c27b0;--scribe-speaker-2: #ff9800;--scribe-speaker-3: #009688;display:block;font-family:var(--scribe-font-family);font-size:var(--scribe-font-size)}.scribe-recorder{background:var(--scribe-bg);border-radius:var(--scribe-border-radius)}.scribe-controls{display:flex;align-items:center;gap:1rem;padding:1rem}.scribe-btn{display:flex;align-items:center;justify-content:center;gap:.5rem;padding:.75rem;border:none;border-radius:var(--scribe-border-radius);font-family:var(--scribe-font-family);font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}.scribe-btn:disabled{opacity:.5;cursor:not-allowed}.scribe-btn--mic{width:48px;height:48px;border-radius:50%;background:var(--scribe-bg-transcript);color:var(--scribe-text-color)}.scribe-btn--mic:hover:not(:disabled){background:var(--scribe-border-color)}.scribe-btn--mic.scribe-btn--active{background:var(--scribe-danger);color:#fff;animation:pulse-recording 1.5s ease-in-out infinite}.scribe-btn--mic.scribe-btn--paused{background:var(--scribe-primary);color:#fff}.scribe-btn--mic.scribe-btn--paused:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-btn--mic.scribe-btn--connecting{background:var(--scribe-primary-light);color:#fff}.scribe-btn--finish{padding:.75rem 1.25rem;background:var(--scribe-primary);color:#fff}.scribe-btn--finish:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-icon{flex-shrink:0}.scribe-icon--spinner{animation:spin 1s linear infinite}.scribe-level{flex:1;height:8px;background:var(--scribe-level-bg);border-radius:4px;overflow:hidden;opacity:.5;transition:opacity .2s ease}.scribe-level--active{opacity:1}.scribe-level__fill{height:100%;background:var(--scribe-level-fill);border-radius:4px;transition:width .05s ease-out}.scribe-transcript{min-height:120px;max-height:400px;overflow-y:auto;padding:1rem;margin:0 1rem 1rem;background:var(--scribe-bg-transcript);border-radius:var(--scribe-border-radius);line-height:1.6}.scribe-transcript--empty{display:flex;align-items:center;justify-content:center}.scribe-placeholder{color:var(--scribe-text-partial);font-style:italic}.scribe-speaker-block{margin-bottom:.75rem;padding-left:.5rem;border-left:3px solid var(--scribe-border-color)}.scribe-speaker-block--partial{opacity:.7}.scribe-speaker-block[data-speaker=\"0\"]{border-left-color:var(--scribe-speaker-0)}.scribe-speaker-block[data-speaker=\"1\"]{border-left-color:var(--scribe-speaker-1)}.scribe-speaker-block[data-speaker=\"2\"]{border-left-color:var(--scribe-speaker-2)}.scribe-speaker-block[data-speaker=\"3\"]{border-left-color:var(--scribe-speaker-3)}.scribe-speaker-label{display:inline-block;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;padding:.125rem .5rem;border-radius:4px;margin-bottom:.25rem;color:#fff;background:var(--scribe-border-color)}.scribe-speaker--0{background:var(--scribe-speaker-0)}.scribe-speaker--1{background:var(--scribe-speaker-1)}.scribe-speaker--2{background:var(--scribe-speaker-2)}.scribe-speaker--3{background:var(--scribe-speaker-3)}.scribe-speaker-text{margin-top:.25rem}.scribe-text{word-wrap:break-word}.scribe-text--final{color:var(--scribe-text-color)}.scribe-text--partial{color:var(--scribe-text-partial);font-style:italic}.scribe-generate-container{position:relative;margin-left:.5rem}.scribe-btn--generate{display:flex;align-items:center;gap:.5rem;padding:.75rem 1.25rem;background:var(--scribe-primary);color:#fff;border:none;border-radius:var(--scribe-border-radius);font-family:var(--scribe-font-family);font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}.scribe-btn--generate:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-btn--generate:disabled{opacity:.7;cursor:not-allowed}.scribe-template-menu{position:absolute;top:100%;left:0;margin-top:.25rem;min-width:220px;background:var(--scribe-bg);border:1px solid var(--scribe-border-color);border-radius:var(--scribe-border-radius);box-shadow:0 4px 12px #00000026;z-index:100;overflow:hidden}.scribe-template-item{display:flex;flex-direction:column;align-items:flex-start;width:100%;padding:.75rem 1rem;border:none;background:transparent;cursor:pointer;text-align:left;transition:background .15s ease}.scribe-template-item:hover{background:var(--scribe-bg-transcript)}.scribe-template-item:not(:last-child){border-bottom:1px solid var(--scribe-border-color)}.scribe-template-name{font-weight:500;color:var(--scribe-text-color)}.scribe-template-desc{font-size:.75rem;color:var(--scribe-text-partial);margin-top:.25rem}@keyframes pulse-recording{0%,to{box-shadow:0 0 #f4433666}50%{box-shadow:0 0 0 8px #f4433600}}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.scribe-playback{padding:1rem}.scribe-loading,.scribe-error{display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--scribe-text-partial)}.scribe-player{display:flex;flex-direction:column;gap:.5rem;padding:1rem;background:var(--scribe-bg-transcript);border-radius:var(--scribe-border-radius);margin-bottom:1rem}.scribe-player-row{display:flex;align-items:center;gap:1rem}.scribe-btn--play{width:48px;height:48px;border-radius:50%;background:var(--scribe-primary);color:#fff;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .2s ease;flex-shrink:0}.scribe-btn--play:hover{background:var(--scribe-primary-dark)}.scribe-timeline-slider{flex:1;cursor:pointer;height:6px;-webkit-appearance:none;appearance:none;background:var(--scribe-level-bg);border-radius:3px;outline:none}.scribe-timeline-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;background:var(--scribe-primary);border-radius:50%;cursor:pointer}.scribe-timeline-slider::-moz-range-thumb{width:14px;height:14px;background:var(--scribe-primary);border-radius:50%;cursor:pointer;border:none}.scribe-time{font-size:.75rem;color:var(--scribe-text-partial);text-align:center}.scribe-speed{padding:.375rem .5rem;border-radius:4px;border:1px solid var(--scribe-border-color);background:var(--scribe-bg);font-size:.875rem;cursor:pointer}.scribe-transcript-playback{max-height:400px;overflow-y:auto;scroll-behavior:smooth;padding:.5rem}.scribe-segment{padding:.75rem 1rem;margin-bottom:.5rem;border-left:3px solid var(--speaker-color, var(--scribe-border-color));background:var(--scribe-bg);border-radius:0 var(--scribe-border-radius) var(--scribe-border-radius) 0;cursor:pointer;transition:background .2s ease}.scribe-segment:hover{background:var(--scribe-bg-transcript)}.scribe-segment--active{background:#4caf501a;border-left-color:var(--scribe-primary)}.scribe-segment-text{margin:.25rem 0 0;line-height:1.6}.scribe-word{transition:background .15s ease,color .15s ease;padding:0 1px;border-radius:2px}.scribe-word:hover{background:#0000000d;cursor:pointer}.scribe-word--active{background:var(--scribe-primary);color:#fff}\n"] }]
1702
+ `, styles: [":host{--scribe-primary: #4caf50;--scribe-primary-dark: #388e3c;--scribe-primary-light: #81c784;--scribe-danger: #f44336;--scribe-danger-dark: #d32f2f;--scribe-text-color: #212121;--scribe-text-partial: #9e9e9e;--scribe-font-family: inherit;--scribe-font-size: 1rem;--scribe-bg: #ffffff;--scribe-bg-transcript: #f5f5f5;--scribe-border-radius: 8px;--scribe-border-color: #e0e0e0;--scribe-level-bg: #e0e0e0;--scribe-level-fill: #4caf50;--scribe-speaker-0: #2196f3;--scribe-speaker-1: #9c27b0;--scribe-speaker-2: #ff9800;--scribe-speaker-3: #009688;display:block;font-family:var(--scribe-font-family);font-size:var(--scribe-font-size)}.scribe-recorder{background:var(--scribe-bg);border-radius:var(--scribe-border-radius)}.scribe-controls{display:flex;align-items:center;gap:1rem;padding:1rem}.scribe-btn{display:flex;align-items:center;justify-content:center;gap:.5rem;padding:.75rem;border:none;border-radius:var(--scribe-border-radius);font-family:var(--scribe-font-family);font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}.scribe-btn:disabled{opacity:.5;cursor:not-allowed}.scribe-btn--mic{width:48px;height:48px;border-radius:50%;background:var(--scribe-bg-transcript);color:var(--scribe-text-color)}.scribe-btn--mic:hover:not(:disabled){background:var(--scribe-border-color)}.scribe-btn--mic.scribe-btn--active{background:var(--scribe-danger);color:#fff;animation:pulse-recording 1.5s ease-in-out infinite}.scribe-btn--mic.scribe-btn--paused{background:var(--scribe-primary);color:#fff}.scribe-btn--mic.scribe-btn--paused:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-btn--mic.scribe-btn--connecting{background:var(--scribe-primary-light);color:#fff}.scribe-btn--finish{padding:.75rem 1.25rem;background:var(--scribe-primary);color:#fff}.scribe-btn--finish:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-icon{flex-shrink:0}.scribe-icon--spinner{animation:spin 1s linear infinite}.scribe-level{flex:1;height:8px;background:var(--scribe-level-bg);border-radius:4px;overflow:hidden;opacity:.5;transition:opacity .2s ease}.scribe-level--active{opacity:1}.scribe-level__fill{height:100%;background:var(--scribe-level-fill);border-radius:4px;transition:width .05s ease-out}.scribe-transcript{min-height:120px;max-height:400px;overflow-y:auto;padding:1rem;margin:0 1rem 1rem;background:var(--scribe-bg-transcript);border-radius:var(--scribe-border-radius);line-height:1.6}.scribe-transcript--empty{display:flex;align-items:center;justify-content:center}.scribe-placeholder{color:var(--scribe-text-partial);font-style:italic}.scribe-speaker-block{margin-bottom:.75rem;padding-left:.5rem;border-left:3px solid var(--scribe-border-color)}.scribe-speaker-block--partial{opacity:.7}.scribe-speaker-block[data-speaker=\"0\"]{border-left-color:var(--scribe-speaker-0)}.scribe-speaker-block[data-speaker=\"1\"]{border-left-color:var(--scribe-speaker-1)}.scribe-speaker-block[data-speaker=\"2\"]{border-left-color:var(--scribe-speaker-2)}.scribe-speaker-block[data-speaker=\"3\"]{border-left-color:var(--scribe-speaker-3)}.scribe-speaker-label{display:inline-block;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;padding:.125rem .5rem;border-radius:4px;margin-bottom:.25rem;color:#fff;background:var(--scribe-border-color)}.scribe-speaker--0{background:var(--scribe-speaker-0)}.scribe-speaker--1{background:var(--scribe-speaker-1)}.scribe-speaker--2{background:var(--scribe-speaker-2)}.scribe-speaker--3{background:var(--scribe-speaker-3)}.scribe-speaker-text{margin-top:.25rem}.scribe-text{word-wrap:break-word}.scribe-text--final{color:var(--scribe-text-color)}.scribe-text--partial{color:var(--scribe-text-partial);font-style:italic}.scribe-generate-container{position:relative;margin-left:.5rem}.scribe-btn--generate{display:flex;align-items:center;gap:.5rem;padding:.75rem 1.25rem;background:var(--scribe-primary);color:#fff;border:none;border-radius:var(--scribe-border-radius);font-family:var(--scribe-font-family);font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s ease}.scribe-btn--generate:hover:not(:disabled){background:var(--scribe-primary-dark)}.scribe-btn--generate:disabled{opacity:.7;cursor:not-allowed}.scribe-template-menu{position:absolute;top:100%;left:0;margin-top:.25rem;min-width:220px;background:var(--scribe-bg);border:1px solid var(--scribe-border-color);border-radius:var(--scribe-border-radius);box-shadow:0 4px 12px #00000026;z-index:100;overflow:hidden}.scribe-template-item{display:flex;flex-direction:column;align-items:flex-start;width:100%;padding:.75rem 1rem;border:none;background:transparent;cursor:pointer;text-align:left;transition:background .15s ease}.scribe-template-item:hover{background:var(--scribe-bg-transcript)}.scribe-template-item:not(:last-child){border-bottom:1px solid var(--scribe-border-color)}.scribe-template-name{font-weight:500;color:var(--scribe-text-color)}.scribe-template-desc{font-size:.75rem;color:var(--scribe-text-partial);margin-top:.25rem}@keyframes pulse-recording{0%,to{box-shadow:0 0 #f4433666}50%{box-shadow:0 0 0 8px #f4433600}}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.scribe-playback{padding:1rem}.scribe-loading,.scribe-error{display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--scribe-text-partial)}.scribe-player{display:flex;align-items:center;gap:1rem;padding:1rem;background:var(--scribe-bg-transcript);border-radius:var(--scribe-border-radius);margin-bottom:1rem}.scribe-btn--play{width:48px;height:48px;border-radius:50%;background:var(--scribe-primary);color:#fff;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .2s ease;flex-shrink:0}.scribe-btn--play:hover{background:var(--scribe-primary-dark)}.scribe-timeline{flex:1;position:relative}.scribe-timeline-slider{width:100%;cursor:pointer;-webkit-appearance:none;appearance:none;background:var(--scribe-level-bg);border-radius:3px;outline:none}.scribe-timeline-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;background:var(--scribe-primary);border-radius:50%;cursor:pointer}.scribe-timeline-slider::-moz-range-thumb{width:14px;height:14px;background:var(--scribe-primary);border-radius:50%;cursor:pointer;border:none}.scribe-time{position:absolute;top:20px;left:50%;transform:translate(-50%);font-size:.75rem;color:var(--scribe-text-partial);white-space:nowrap}.scribe-speed{padding:.375rem .5rem;border-radius:4px;border:1px solid var(--scribe-border-color);background:var(--scribe-bg);font-size:.875rem;cursor:pointer}.scribe-transcript-playback{max-height:400px;overflow-y:auto;scroll-behavior:smooth;padding:.5rem}.scribe-segment{padding:.75rem 1rem;margin-bottom:.5rem;border-left:3px solid var(--speaker-color, var(--scribe-border-color));background:var(--scribe-bg);border-radius:0 var(--scribe-border-radius) var(--scribe-border-radius) 0;cursor:pointer;transition:background .2s ease}.scribe-segment:hover{background:var(--scribe-bg-transcript)}.scribe-segment--active{background:#4caf501a;border-left-color:var(--scribe-primary)}.scribe-segment-text{margin:.25rem 0 0;line-height:1.6}.scribe-word{transition:background .15s ease,color .15s ease;padding:0 1px;border-radius:2px}.scribe-word:hover{background:#0000000d;cursor:pointer}.scribe-word--active{background:var(--scribe-primary);color:#fff}.scribe-speaker-label-wrapper{position:relative;display:inline-block}.scribe-speaker-label--editable{cursor:pointer;display:inline-flex;align-items:center;gap:4px;border:1px solid transparent;transition:all .15s ease}.scribe-speaker-label--editable:hover{border-color:#ffffff4d}.scribe-speaker-label--low-confidence{border-color:var(--scribe-warning, #f59e0b)!important}.scribe-chevron{opacity:.7}.scribe-warning{font-size:.875rem;margin-left:2px}.scribe-speaker-dropdown{position:absolute;top:100%;left:0;z-index:100;min-width:140px;margin-top:4px;background:var(--scribe-bg);border:1px solid var(--scribe-border-color);border-radius:6px;box-shadow:0 4px 12px #00000026;padding:4px}.scribe-dropdown-item{display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;border:none;background:none;cursor:pointer;font-family:var(--scribe-font-family);font-size:.875rem;text-align:left;border-radius:4px;color:var(--scribe-text-color)}.scribe-dropdown-item:hover{background:var(--scribe-bg-transcript)}.scribe-dropdown-icon{font-size:1rem}.scribe-dropdown-divider{height:1px;background:var(--scribe-border-color);margin:4px 0}\n"] }]
1447
1703
  }], ctorParameters: () => [], propDecorators: { onDocumentClick: [{
1448
1704
  type: HostListener,
1449
1705
  args: ['document:click', ['$event']]