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

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,15 @@ 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 }) => {
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 }) => {
568
884
  this.socket.sendAudioChunk(data, isSilence);
569
885
  }));
570
886
  effect(() => {
571
887
  const sid = this.sessionId();
572
888
  const api = this.apiUrl();
573
889
  if (sid && api && this.mode() === 'playback') {
574
- this.loadSession();
890
+ this.loadPlaybackSession();
575
891
  }
576
892
  });
577
893
  effect(() => {
@@ -581,56 +897,36 @@ class RecorderComponent {
581
897
  this.resumeSession(resumeId);
582
898
  }
583
899
  });
900
+ effect(() => {
901
+ if (this.mode() !== 'recording')
902
+ return;
903
+ const allEntries = this.entries();
904
+ if (this.speakerService.shouldIdentify(allEntries)) {
905
+ this.speakerService.scheduleIdentification(() => this.triggerIdentification());
906
+ }
907
+ });
584
908
  }
585
909
  ngOnDestroy() {
586
910
  this.subscriptions.forEach((sub) => sub.unsubscribe());
587
911
  this.generateAbortController?.abort();
588
912
  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
- }
913
+ this.playback.cleanup();
914
+ this.speakerService.reset();
611
915
  }
612
916
  getSpeakerLabel(speaker) {
613
- const customLabels = this.speakerLabels();
614
- if (customLabels[speaker]) {
615
- return customLabels[speaker];
616
- }
617
- return SPEAKER_LABELS[speaker] || `Pessoa ${speaker + 1}`;
917
+ return this.speakerService.getLabel(speaker, this.speakerLabels(), this.doctorName(), this.patientName(), this.companionName());
618
918
  }
619
919
  shouldShowSpeakerLabel(segmentIndex) {
620
- const segs = this.segments();
920
+ const segs = this.playback.segments;
621
921
  if (segmentIndex === 0)
622
922
  return true;
623
- const currentSpeaker = segs[segmentIndex]?.speaker;
624
- const previousSpeaker = segs[segmentIndex - 1]?.speaker;
625
- return currentSpeaker !== previousSpeaker;
923
+ return segs[segmentIndex]?.speaker !== segs[segmentIndex - 1]?.speaker;
626
924
  }
627
925
  getMicButtonLabel() {
628
- if (this.isPaused()) {
926
+ if (this.isPaused())
629
927
  return 'Retomar gravação';
630
- }
631
- if (this.isRecording()) {
928
+ if (this.isRecording())
632
929
  return 'Pausar gravação';
633
- }
634
930
  return 'Iniciar gravação';
635
931
  }
636
932
  async toggleRecording() {
@@ -698,10 +994,7 @@ class RecorderComponent {
698
994
  method: 'POST',
699
995
  headers,
700
996
  signal: this.generateAbortController.signal,
701
- body: JSON.stringify({
702
- sessionId,
703
- outputSchema: template.content,
704
- }),
997
+ body: JSON.stringify({ sessionId, outputSchema: template.content }),
705
998
  });
706
999
  if (!response.ok) {
707
1000
  const errorData = await response.json().catch(() => ({}));
@@ -724,32 +1017,51 @@ class RecorderComponent {
724
1017
  this.isGenerating.set(false);
725
1018
  }
726
1019
  }
727
- async loadSession() {
1020
+ onSeek(event) {
1021
+ const input = event.target;
1022
+ if (input) {
1023
+ this.playback.seekTo(parseFloat(input.value));
1024
+ }
1025
+ }
1026
+ onPlaybackRateChange(event) {
1027
+ const select = event.target;
1028
+ if (select) {
1029
+ this.playback.setRate(parseFloat(select.value));
1030
+ }
1031
+ }
1032
+ onWordClick(word, event) {
1033
+ event.stopPropagation();
1034
+ this.playback.seekToWord(word);
1035
+ }
1036
+ onSpeakerLabelClick(event, speaker) {
1037
+ event.stopPropagation();
1038
+ this.speakerService.toggleMenu(speaker);
1039
+ }
1040
+ setSpeakerRole(speaker, role) {
1041
+ this.speakerService.setRole(speaker, role);
1042
+ }
1043
+ getSpeakerColor(speaker) {
1044
+ if (speaker === undefined)
1045
+ return 'var(--scribe-border-color)';
1046
+ const colors = [
1047
+ 'var(--scribe-speaker-0)',
1048
+ 'var(--scribe-speaker-1)',
1049
+ 'var(--scribe-speaker-2)',
1050
+ 'var(--scribe-speaker-3)',
1051
+ ];
1052
+ return colors[speaker % colors.length];
1053
+ }
1054
+ async loadPlaybackSession() {
728
1055
  const sid = this.sessionId();
729
1056
  const api = this.apiUrl();
730
1057
  if (!sid || !api)
731
1058
  return;
732
- this.isLoadingSession.set(true);
733
1059
  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);
1060
+ await this.playback.loadSession(api, sid, this.token());
746
1061
  }
747
1062
  catch (err) {
748
1063
  this.error.emit(err instanceof Error ? err : new Error(String(err)));
749
1064
  }
750
- finally {
751
- this.isLoadingSession.set(false);
752
- }
753
1065
  }
754
1066
  async resumeSession(sessionId) {
755
1067
  const api = this.apiUrl();
@@ -786,125 +1098,6 @@ class RecorderComponent {
786
1098
  this.isLoadingSession.set(false);
787
1099
  }
788
1100
  }
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
1101
  async startRecording() {
909
1102
  try {
910
1103
  await this.socket.connect(this.wsUrl(), this.token(), this.premium());
@@ -954,78 +1147,83 @@ class RecorderComponent {
954
1147
  this.currentSpeaker.set(event.speaker);
955
1148
  }
956
1149
  }
1150
+ async triggerIdentification() {
1151
+ const api = this.apiUrl() || this.lambdaUrl();
1152
+ const sessionId = this.socket.getSessionId() || this.lastSessionId();
1153
+ await this.speakerService.identify(this.entries(), api, this.token(), sessionId, this.premium());
1154
+ }
957
1155
  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: `
1156
+ 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
1157
  <div class="scribe-recorder">
960
1158
  @if (mode() === 'playback') {
961
1159
  <!-- Playback Mode -->
962
1160
  <div class="scribe-playback">
963
- @if (isLoadingSession()) {
1161
+ @if (playback.isLoading()) {
964
1162
  <div class="scribe-loading">
965
1163
  <svg class="scribe-icon scribe-icon--spinner" viewBox="0 0 24 24" width="24" height="24">
966
1164
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"/>
967
1165
  </svg>
968
1166
  <span>Carregando sessão...</span>
969
1167
  </div>
970
- } @else if (sessionData()) {
1168
+ } @else if (playback.sessionData()) {
971
1169
  <!-- Audio Player -->
972
1170
  <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>
1171
+ <button
1172
+ class="scribe-btn scribe-btn--play"
1173
+ (click)="playback.togglePlayback()"
1174
+ [attr.aria-label]="playback.isPlaying() ? 'Pausar' : 'Reproduzir'"
1175
+ >
1176
+ @if (playback.isPlaying()) {
1177
+ <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1178
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1179
+ </svg>
1180
+ } @else {
1181
+ <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1182
+ <path d="M8 5v14l11-7z"/>
1183
+ </svg>
1184
+ }
1185
+ </button>
989
1186
 
1187
+ <div class="scribe-timeline">
990
1188
  <input
991
1189
  type="range"
992
1190
  class="scribe-timeline-slider"
993
1191
  [min]="0"
994
- [max]="audioDuration()"
995
- [value]="audioCurrentTime()"
1192
+ [max]="playback.duration()"
1193
+ [value]="playback.currentTime()"
996
1194
  step="0.1"
997
- (input)="seekTo($event)"
1195
+ (input)="onSeek($event)"
998
1196
  aria-label="Posição do áudio"
999
1197
  />
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()) }}
1198
+ <div class="scribe-time">
1199
+ {{ playback.formatTime(playback.currentTime()) }} / {{ playback.formatTime(playback.duration()) }}
1200
+ </div>
1017
1201
  </div>
1202
+
1203
+ <select
1204
+ class="scribe-speed"
1205
+ [value]="playback.playbackRate()"
1206
+ (change)="onPlaybackRateChange($event)"
1207
+ aria-label="Velocidade de reprodução"
1208
+ >
1209
+ <option value="0.5">0.5x</option>
1210
+ <option value="0.75">0.75x</option>
1211
+ <option value="1">1x</option>
1212
+ <option value="1.25">1.25x</option>
1213
+ <option value="1.5">1.5x</option>
1214
+ <option value="2">2x</option>
1215
+ </select>
1018
1216
  </div>
1019
1217
 
1020
1218
  <!-- Transcription with Highlight -->
1021
1219
  <div class="scribe-transcript-playback">
1022
- @for (segment of segments(); track segment.id; let idx = $index) {
1220
+ @for (segment of playback.segments; track segment.id; let idx = $index) {
1023
1221
  <div
1024
1222
  class="scribe-segment"
1025
- [class.scribe-segment--active]="activeSegmentId() === segment.id"
1223
+ [class.scribe-segment--active]="playback.activeSegmentId() === segment.id"
1026
1224
  [style.--speaker-color]="getSpeakerColor(segment.speaker)"
1027
1225
  [attr.data-segment-id]="segment.id"
1028
- (click)="seekToSegment(segment)"
1226
+ (click)="playback.seekToSegment(segment)"
1029
1227
  >
1030
1228
  @if (segment.speaker !== undefined && shouldShowSpeakerLabel(idx)) {
1031
1229
  <span class="scribe-speaker-label" [class]="'scribe-speaker--' + segment.speaker">
@@ -1038,8 +1236,8 @@ class RecorderComponent {
1038
1236
  @for (word of segment.words; track $index; let i = $index) {
1039
1237
  <span
1040
1238
  class="scribe-word"
1041
- [class.scribe-word--active]="activeSegmentId() === segment.id && activeWordIndex() === i"
1042
- (click)="seekToWord(word, $event)"
1239
+ [class.scribe-word--active]="playback.activeSegmentId() === segment.id && playback.activeWordIndex() === i"
1240
+ (click)="onWordClick(word, $event)"
1043
1241
  >{{ word.word }}</span>{{ ' ' }}
1044
1242
  }
1045
1243
  } @else {
@@ -1048,7 +1246,7 @@ class RecorderComponent {
1048
1246
  </p>
1049
1247
  </div>
1050
1248
  }
1051
- @if (segments().length === 0) {
1249
+ @if (playback.segments.length === 0) {
1052
1250
  <span class="scribe-placeholder">Nenhuma transcrição disponível.</span>
1053
1251
  }
1054
1252
  </div>
@@ -1084,17 +1282,14 @@ class RecorderComponent {
1084
1282
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"/>
1085
1283
  </svg>
1086
1284
  } @else if (isPaused()) {
1087
- <!-- Play icon when paused -->
1088
1285
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1089
1286
  <path d="M8 5v14l11-7z"/>
1090
1287
  </svg>
1091
1288
  } @else if (isRecording()) {
1092
- <!-- Pause icon when recording -->
1093
1289
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1094
1290
  <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1095
1291
  </svg>
1096
1292
  } @else {
1097
- <!-- Mic icon when idle -->
1098
1293
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1099
1294
  <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
1295
  <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 +1364,39 @@ class RecorderComponent {
1169
1364
  @for (group of speakerGroups(); track $index) {
1170
1365
  <div class="scribe-speaker-block" [attr.data-speaker]="group.speaker">
1171
1366
  @if (group.speaker !== undefined && group.speaker !== null) {
1172
- <span class="scribe-speaker-label" [class]="'scribe-speaker--' + group.speaker">
1173
- {{ getSpeakerLabel(group.speaker) }}
1367
+ <span class="scribe-speaker-label-wrapper">
1368
+ <button
1369
+ type="button"
1370
+ class="scribe-speaker-label scribe-speaker-label--editable"
1371
+ [class]="'scribe-speaker--' + group.speaker"
1372
+ [class.scribe-speaker-label--low-confidence]="speakerService.hasLowConfidence(group.speaker)"
1373
+ (click)="onSpeakerLabelClick($event, group.speaker)"
1374
+ >
1375
+ {{ getSpeakerLabel(group.speaker) }}
1376
+ <svg class="scribe-chevron" viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
1377
+ <path d="M7 10l5 5 5-5z"/>
1378
+ </svg>
1379
+ @if (speakerService.hasLowConfidence(group.speaker)) {
1380
+ <span class="scribe-warning" title="Confiança baixa - verifique">⚠️</span>
1381
+ }
1382
+ </button>
1383
+ @if (speakerService.editingSpeaker() === group.speaker) {
1384
+ <div class="scribe-speaker-dropdown">
1385
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'doctor')">
1386
+ <span class="scribe-dropdown-icon">🩺</span> Médico
1387
+ </button>
1388
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'patient')">
1389
+ <span class="scribe-dropdown-icon">🙋</span> Paciente
1390
+ </button>
1391
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'companion')">
1392
+ <span class="scribe-dropdown-icon">👥</span> Acompanhante
1393
+ </button>
1394
+ <div class="scribe-dropdown-divider"></div>
1395
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'other')">
1396
+ <span class="scribe-dropdown-icon">✏️</span> Outro
1397
+ </button>
1398
+ </div>
1399
+ }
1174
1400
  </span>
1175
1401
  }
1176
1402
  <div class="scribe-speaker-text">
@@ -1197,81 +1423,81 @@ class RecorderComponent {
1197
1423
  }
1198
1424
  }
1199
1425
  </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 }] });
1426
+ `, 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
1427
  }
1202
1428
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImport: i0, type: RecorderComponent, decorators: [{
1203
1429
  type: Component,
1204
- args: [{ selector: 'ngx-jaimes-scribe-recorder', standalone: true, imports: [CommonModule], template: `
1430
+ args: [{ selector: 'ngx-jaimes-scribe-recorder', standalone: true, imports: [CommonModule], providers: [SpeakerIdentificationService, PlaybackService], template: `
1205
1431
  <div class="scribe-recorder">
1206
1432
  @if (mode() === 'playback') {
1207
1433
  <!-- Playback Mode -->
1208
1434
  <div class="scribe-playback">
1209
- @if (isLoadingSession()) {
1435
+ @if (playback.isLoading()) {
1210
1436
  <div class="scribe-loading">
1211
1437
  <svg class="scribe-icon scribe-icon--spinner" viewBox="0 0 24 24" width="24" height="24">
1212
1438
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"/>
1213
1439
  </svg>
1214
1440
  <span>Carregando sessão...</span>
1215
1441
  </div>
1216
- } @else if (sessionData()) {
1442
+ } @else if (playback.sessionData()) {
1217
1443
  <!-- Audio Player -->
1218
1444
  <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>
1445
+ <button
1446
+ class="scribe-btn scribe-btn--play"
1447
+ (click)="playback.togglePlayback()"
1448
+ [attr.aria-label]="playback.isPlaying() ? 'Pausar' : 'Reproduzir'"
1449
+ >
1450
+ @if (playback.isPlaying()) {
1451
+ <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1452
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1453
+ </svg>
1454
+ } @else {
1455
+ <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1456
+ <path d="M8 5v14l11-7z"/>
1457
+ </svg>
1458
+ }
1459
+ </button>
1235
1460
 
1461
+ <div class="scribe-timeline">
1236
1462
  <input
1237
1463
  type="range"
1238
1464
  class="scribe-timeline-slider"
1239
1465
  [min]="0"
1240
- [max]="audioDuration()"
1241
- [value]="audioCurrentTime()"
1466
+ [max]="playback.duration()"
1467
+ [value]="playback.currentTime()"
1242
1468
  step="0.1"
1243
- (input)="seekTo($event)"
1469
+ (input)="onSeek($event)"
1244
1470
  aria-label="Posição do áudio"
1245
1471
  />
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()) }}
1472
+ <div class="scribe-time">
1473
+ {{ playback.formatTime(playback.currentTime()) }} / {{ playback.formatTime(playback.duration()) }}
1474
+ </div>
1263
1475
  </div>
1476
+
1477
+ <select
1478
+ class="scribe-speed"
1479
+ [value]="playback.playbackRate()"
1480
+ (change)="onPlaybackRateChange($event)"
1481
+ aria-label="Velocidade de reprodução"
1482
+ >
1483
+ <option value="0.5">0.5x</option>
1484
+ <option value="0.75">0.75x</option>
1485
+ <option value="1">1x</option>
1486
+ <option value="1.25">1.25x</option>
1487
+ <option value="1.5">1.5x</option>
1488
+ <option value="2">2x</option>
1489
+ </select>
1264
1490
  </div>
1265
1491
 
1266
1492
  <!-- Transcription with Highlight -->
1267
1493
  <div class="scribe-transcript-playback">
1268
- @for (segment of segments(); track segment.id; let idx = $index) {
1494
+ @for (segment of playback.segments; track segment.id; let idx = $index) {
1269
1495
  <div
1270
1496
  class="scribe-segment"
1271
- [class.scribe-segment--active]="activeSegmentId() === segment.id"
1497
+ [class.scribe-segment--active]="playback.activeSegmentId() === segment.id"
1272
1498
  [style.--speaker-color]="getSpeakerColor(segment.speaker)"
1273
1499
  [attr.data-segment-id]="segment.id"
1274
- (click)="seekToSegment(segment)"
1500
+ (click)="playback.seekToSegment(segment)"
1275
1501
  >
1276
1502
  @if (segment.speaker !== undefined && shouldShowSpeakerLabel(idx)) {
1277
1503
  <span class="scribe-speaker-label" [class]="'scribe-speaker--' + segment.speaker">
@@ -1284,8 +1510,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1284
1510
  @for (word of segment.words; track $index; let i = $index) {
1285
1511
  <span
1286
1512
  class="scribe-word"
1287
- [class.scribe-word--active]="activeSegmentId() === segment.id && activeWordIndex() === i"
1288
- (click)="seekToWord(word, $event)"
1513
+ [class.scribe-word--active]="playback.activeSegmentId() === segment.id && playback.activeWordIndex() === i"
1514
+ (click)="onWordClick(word, $event)"
1289
1515
  >{{ word.word }}</span>{{ ' ' }}
1290
1516
  }
1291
1517
  } @else {
@@ -1294,7 +1520,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1294
1520
  </p>
1295
1521
  </div>
1296
1522
  }
1297
- @if (segments().length === 0) {
1523
+ @if (playback.segments.length === 0) {
1298
1524
  <span class="scribe-placeholder">Nenhuma transcrição disponível.</span>
1299
1525
  }
1300
1526
  </div>
@@ -1330,17 +1556,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1330
1556
  <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-linecap="round"/>
1331
1557
  </svg>
1332
1558
  } @else if (isPaused()) {
1333
- <!-- Play icon when paused -->
1334
1559
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1335
1560
  <path d="M8 5v14l11-7z"/>
1336
1561
  </svg>
1337
1562
  } @else if (isRecording()) {
1338
- <!-- Pause icon when recording -->
1339
1563
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1340
1564
  <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
1341
1565
  </svg>
1342
1566
  } @else {
1343
- <!-- Mic icon when idle -->
1344
1567
  <svg class="scribe-icon" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
1345
1568
  <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
1569
  <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 +1638,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1415
1638
  @for (group of speakerGroups(); track $index) {
1416
1639
  <div class="scribe-speaker-block" [attr.data-speaker]="group.speaker">
1417
1640
  @if (group.speaker !== undefined && group.speaker !== null) {
1418
- <span class="scribe-speaker-label" [class]="'scribe-speaker--' + group.speaker">
1419
- {{ getSpeakerLabel(group.speaker) }}
1641
+ <span class="scribe-speaker-label-wrapper">
1642
+ <button
1643
+ type="button"
1644
+ class="scribe-speaker-label scribe-speaker-label--editable"
1645
+ [class]="'scribe-speaker--' + group.speaker"
1646
+ [class.scribe-speaker-label--low-confidence]="speakerService.hasLowConfidence(group.speaker)"
1647
+ (click)="onSpeakerLabelClick($event, group.speaker)"
1648
+ >
1649
+ {{ getSpeakerLabel(group.speaker) }}
1650
+ <svg class="scribe-chevron" viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
1651
+ <path d="M7 10l5 5 5-5z"/>
1652
+ </svg>
1653
+ @if (speakerService.hasLowConfidence(group.speaker)) {
1654
+ <span class="scribe-warning" title="Confiança baixa - verifique">⚠️</span>
1655
+ }
1656
+ </button>
1657
+ @if (speakerService.editingSpeaker() === group.speaker) {
1658
+ <div class="scribe-speaker-dropdown">
1659
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'doctor')">
1660
+ <span class="scribe-dropdown-icon">🩺</span> Médico
1661
+ </button>
1662
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'patient')">
1663
+ <span class="scribe-dropdown-icon">🙋</span> Paciente
1664
+ </button>
1665
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'companion')">
1666
+ <span class="scribe-dropdown-icon">👥</span> Acompanhante
1667
+ </button>
1668
+ <div class="scribe-dropdown-divider"></div>
1669
+ <button type="button" class="scribe-dropdown-item" (click)="setSpeakerRole(group.speaker, 'other')">
1670
+ <span class="scribe-dropdown-icon">✏️</span> Outro
1671
+ </button>
1672
+ </div>
1673
+ }
1420
1674
  </span>
1421
1675
  }
1422
1676
  <div class="scribe-speaker-text">
@@ -1443,7 +1697,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.18", ngImpo
1443
1697
  }
1444
1698
  }
1445
1699
  </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"] }]
1700
+ `, 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
1701
  }], ctorParameters: () => [], propDecorators: { onDocumentClick: [{
1448
1702
  type: HostListener,
1449
1703
  args: ['document:click', ['$event']]