feat: OPD speech-to-text mic, medicine label enhancements & template search
- Add complaints Speech-to-Text (browser + server STT fallback) with interim preview - Add Medical Mic (server STT) for examination, diagnosis, provisional diagnosis fields - Add speech-test route + Home::speechTest() view for STT testing - OPD prescription: resolve dosage/when/frequency labels from lookup tables - OPD prescription: show generic/salt name below medicine name in print - OPD prescription: translate complaints/diagnosis/advice/investigation to Hindi for patient - Add translateToLocalPatientText() helper with English->Hindi medical term map - Add 'Remove All' medicines button (btn_clear_medicine) - Add template search box in Load Template modal - Fix investigation batch add: dedup + guard against concurrent add - Fix single investigation add: check duplicate before adding - Fix rx status text overflow: ellipsis + title tooltip - Remove unused FHIR History and Back to Invoice buttons from Rx header - .gitignore: exclude public/uploads/* and writable/ayushman/*
This commit is contained in:
parent
8e981cdf15
commit
17e2d8e16b
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -63,6 +63,8 @@ writable/uploads/*
|
|||
writable/debugbar/*
|
||||
!writable/debugbar/index.html
|
||||
|
||||
writable/ayushman/*
|
||||
|
||||
php_errors.log
|
||||
|
||||
#-------------------------
|
||||
|
|
@ -124,3 +126,6 @@ _modules/*
|
|||
|
||||
/results/
|
||||
/phpunit*.xml
|
||||
|
||||
public/uploads/*
|
||||
!public/uploads/.gitkeep
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ $routes->get('/', 'Home::index');
|
|||
$routes->get('dashboard', 'Home::dashboard');
|
||||
$routes->get('my-profile', 'Home::myProfile');
|
||||
$routes->post('my-profile/save', 'Home::myProfileSave');
|
||||
$routes->get('speech-test', 'Home::speechTest');
|
||||
|
||||
// Pharmacy (Medical) - phase 1
|
||||
$routes->get('Medical', 'Medical::index');
|
||||
|
|
|
|||
|
|
@ -236,6 +236,16 @@ class Home extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
public function speechTest()
|
||||
{
|
||||
$authRedirect = $this->ensureAuthenticated();
|
||||
if ($authRedirect !== null) {
|
||||
return $authRedirect;
|
||||
}
|
||||
|
||||
return view('tools/speech_test');
|
||||
}
|
||||
|
||||
public function myProfileSave()
|
||||
{
|
||||
$authRedirect = $this->ensureAuthenticated();
|
||||
|
|
|
|||
|
|
@ -1745,14 +1745,21 @@ class Opd extends BaseController
|
|||
$i = 0;
|
||||
foreach ($rxMeds as $med) {
|
||||
$i++;
|
||||
$name = trim((string) ($med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? ''));
|
||||
$dose = trim((string) ($med['drug_dose'] ?? $med['dose'] ?? ''));
|
||||
$freq = trim((string) ($med['drug_freq'] ?? $med['frequency'] ?? ''));
|
||||
$days = trim((string) ($med['drug_day'] ?? $med['days'] ?? ''));
|
||||
$inst = trim((string) ($med['drug_instruction'] ?? $med['instruction'] ?? ''));
|
||||
$name = trim((string) ($med['med_name'] ?? $med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? ''));
|
||||
$generic = trim((string) ($med['genericname'] ?? ($med['generic_name'] ?? ($med['salt_name'] ?? ''))));
|
||||
$dose = trim((string) ($med['dosage_label'] ?? $med['dosage'] ?? $med['drug_dose'] ?? $med['dose'] ?? ''));
|
||||
$freq = trim((string) ($med['dosage_freq_label'] ?? $med['dosage_freq'] ?? $med['drug_freq'] ?? $med['frequency'] ?? ''));
|
||||
$days = trim((string) ($med['no_of_days'] ?? $med['drug_day'] ?? $med['days'] ?? ''));
|
||||
$when = trim((string) ($med['dosage_when_label'] ?? $med['dosage_when'] ?? ''));
|
||||
$remark = trim((string) ($med['remark'] ?? ''));
|
||||
$inst = trim(($when !== '' ? $when : '') . ($remark !== '' ? ($when !== '' ? ' | ' : '') . $remark : ''));
|
||||
$nameHtml = esc($name);
|
||||
if ($generic !== '') {
|
||||
$nameHtml .= '<div style="font-size:10px;color:#444;">Salt/Generic: ' . esc($generic) . '</div>';
|
||||
}
|
||||
$medicalHtml .= '<tr>'
|
||||
. '<td>' . $i . '</td>'
|
||||
. '<td>' . esc($name) . '</td>'
|
||||
. '<td>' . $nameHtml . '</td>'
|
||||
. '<td>' . esc(trim($dose . ' ' . $freq)) . '</td>'
|
||||
. '<td>' . esc($days) . '</td>'
|
||||
. '<td>' . esc($inst) . '</td>'
|
||||
|
|
@ -1785,6 +1792,26 @@ class Opd extends BaseController
|
|||
$rxAdvice = implode(', ', $parts);
|
||||
}
|
||||
|
||||
$complaintText = (string) ($rx['complaints'] ?? '');
|
||||
$diagnosisText = (string) (($rx['Provisional_diagnosis'] ?? '') !== '' ? ($rx['Provisional_diagnosis'] ?? '') : ($rx['diagnosis'] ?? ''));
|
||||
$complaintLocal = $this->translateToLocalPatientText($complaintText);
|
||||
$diagnosisLocal = $this->translateToLocalPatientText($diagnosisText);
|
||||
$investigationLocal = $this->translateToLocalPatientText($rxInvestigation);
|
||||
$rxAdviceLocal = $this->translateToLocalPatientText($rxAdvice);
|
||||
|
||||
if (strtolower(trim($complaintLocal)) === strtolower(trim($complaintText))) {
|
||||
$complaintLocal = '';
|
||||
}
|
||||
if (strtolower(trim($diagnosisLocal)) === strtolower(trim($diagnosisText))) {
|
||||
$diagnosisLocal = '';
|
||||
}
|
||||
if (strtolower(trim($investigationLocal)) === strtolower(trim($rxInvestigation))) {
|
||||
$investigationLocal = '';
|
||||
}
|
||||
if (strtolower(trim($rxAdviceLocal)) === strtolower(trim($rxAdvice))) {
|
||||
$rxAdviceLocal = '';
|
||||
}
|
||||
|
||||
$bp = trim((string) ($rx['bp'] ?? ''));
|
||||
$diastolic = trim((string) ($rx['diastolic'] ?? ''));
|
||||
$pulse = trim((string) ($rx['pulse'] ?? ''));
|
||||
|
|
@ -1856,10 +1883,13 @@ class Opd extends BaseController
|
|||
'total_no_visit' => $totalNoVisit,
|
||||
'last_opdvisit_date' => $lastVisitText,
|
||||
'str_opd_book_date' => $bookTime,
|
||||
'Complaint' => (string) ($rx['complaints'] ?? ''),
|
||||
'diagnosis' => (string) (($rx['Provisional_diagnosis'] ?? '') !== '' ? ($rx['Provisional_diagnosis'] ?? '') : ($rx['diagnosis'] ?? '')),
|
||||
'Complaint' => $complaintText,
|
||||
'Complaint_local' => $complaintLocal,
|
||||
'diagnosis' => $diagnosisText,
|
||||
'diagnosis_local' => $diagnosisLocal,
|
||||
'Provisional_diagnosis' => (string) ($rx['Provisional_diagnosis'] ?? ''),
|
||||
'investigation' => $rxInvestigation,
|
||||
'investigation_local' => $investigationLocal,
|
||||
'medical' => $medicalHtml,
|
||||
'doctor' => '<p style="text-align:right;">Dr. ' . esc((string) ($opd->doc_name ?? '')) . '</p>',
|
||||
'top_content' => '',
|
||||
|
|
@ -1867,6 +1897,7 @@ class Opd extends BaseController
|
|||
'Finding_Examinations' => (string) ($rx['Finding_Examinations'] ?? ''),
|
||||
'Prescriber_Remarks' => (string) ($rx['Prescriber_Remarks'] ?? ''),
|
||||
'advice' => $rxAdvice,
|
||||
'advice_local' => $rxAdviceLocal,
|
||||
'next_visit' => (string) ($rx['next_visit'] ?? ''),
|
||||
'refer_to' => (string) ($rx['refer_to'] ?? ''),
|
||||
'painscale' => '',
|
||||
|
|
@ -1877,6 +1908,69 @@ class Opd extends BaseController
|
|||
];
|
||||
}
|
||||
|
||||
private function translateToLocalPatientText(string $input): string
|
||||
{
|
||||
$text = trim($input);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$map = [
|
||||
'before food' => 'भोजन से पहले',
|
||||
'after food' => 'भोजन के बाद',
|
||||
'after breakfast' => 'नाश्ते के बाद',
|
||||
'before breakfast' => 'नाश्ते से पहले',
|
||||
'after lunch' => 'दोपहर के भोजन के बाद',
|
||||
'before lunch' => 'दोपहर के भोजन से पहले',
|
||||
'after dinner' => 'रात के भोजन के बाद',
|
||||
'before dinner' => 'रात के भोजन से पहले',
|
||||
'morning' => 'सुबह',
|
||||
'afternoon' => 'दोपहर',
|
||||
'evening' => 'शाम',
|
||||
'night' => 'रात',
|
||||
'once daily' => 'दिन में एक बार',
|
||||
'twice daily' => 'दिन में दो बार',
|
||||
'thrice daily' => 'दिन में तीन बार',
|
||||
'daily' => 'रोज',
|
||||
'weekly' => 'साप्ताहिक',
|
||||
'for' => 'के लिए',
|
||||
'days' => 'दिन',
|
||||
'day' => 'दिन',
|
||||
'tab' => 'टैबलेट',
|
||||
'cap' => 'कैप्सूल',
|
||||
'syp' => 'सिरप',
|
||||
'inj' => 'इंजेक्शन',
|
||||
'apply' => 'लगाएं',
|
||||
'continue' => 'जारी रखें',
|
||||
'rest' => 'आराम करें',
|
||||
'hydrate' => 'पर्याप्त पानी लें',
|
||||
'avoid oily food' => 'तैलीय भोजन से बचें',
|
||||
'avoid spicy food' => 'मसालेदार भोजन से बचें',
|
||||
'follow up' => 'फॉलो-अप करें',
|
||||
'review' => 'पुनः जांच',
|
||||
'advice' => 'सलाह',
|
||||
'investigation' => 'जांच',
|
||||
'diagnosis' => 'निदान',
|
||||
'fever' => 'बुखार',
|
||||
'cough' => 'खांसी',
|
||||
'cold' => 'जुकाम',
|
||||
'headache' => 'सिर दर्द',
|
||||
'abdominal pain' => 'पेट दर्द',
|
||||
];
|
||||
|
||||
uksort($map, static function ($a, $b) {
|
||||
return strlen((string) $b) <=> strlen((string) $a);
|
||||
});
|
||||
|
||||
$translated = $text;
|
||||
foreach ($map as $en => $local) {
|
||||
$translated = preg_replace('/\b' . preg_quote($en, '/') . '\b/i', $local, $translated) ?? $translated;
|
||||
}
|
||||
|
||||
$translated = preg_replace('/\s+/', ' ', trim($translated)) ?? trim($translated);
|
||||
return $translated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $vars
|
||||
*/
|
||||
|
|
@ -2221,6 +2315,11 @@ class Opd extends BaseController
|
|||
$tokens['doctor_sign_html'] = nl2br((string) ($data['opd_master'][0]->doc_sign ?? ''));
|
||||
$tokens['content'] = (string) ($tokens['content'] ?? '');
|
||||
|
||||
$adviceLocal = trim((string) ($tokens['advice_local'] ?? ''));
|
||||
$tokens['advice_local_line'] = $adviceLocal !== ''
|
||||
? ('<br><strong>सलाह:</strong> ' . $adviceLocal)
|
||||
: '';
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
|
|
@ -2499,6 +2598,115 @@ class Opd extends BaseController
|
|||
$medBuilder->orderBy('id', 'ASC');
|
||||
}
|
||||
$data['rx_medicines'] = $medBuilder->get()->getResultArray();
|
||||
|
||||
if (!empty($data['rx_medicines'])) {
|
||||
$mapDose = [];
|
||||
$mapWhen = [];
|
||||
$mapFreq = [];
|
||||
$medGenericMap = [];
|
||||
$missingGenericMedIds = [];
|
||||
|
||||
if ($this->db->tableExists('opd_dose_shed')) {
|
||||
$f = $this->db->getFieldNames('opd_dose_shed');
|
||||
$idF = $this->resolveFirstField($f, ['dose_shed_id', 'id']);
|
||||
$labelF = $this->resolveFirstField($f, ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']);
|
||||
if ($idF !== null && $labelF !== null) {
|
||||
$rows = $this->db->table('opd_dose_shed')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$id = trim((string) ($r['id'] ?? ''));
|
||||
$label = trim((string) ($r['label'] ?? ''));
|
||||
if ($id !== '' && $label !== '') {
|
||||
$mapDose[$id] = $label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('opd_dose_when')) {
|
||||
$f = $this->db->getFieldNames('opd_dose_when');
|
||||
$idF = $this->resolveFirstField($f, ['dose_when_id', 'id']);
|
||||
$labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']);
|
||||
if ($idF !== null && $labelF !== null) {
|
||||
$rows = $this->db->table('opd_dose_when')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$id = trim((string) ($r['id'] ?? ''));
|
||||
$label = trim((string) ($r['label'] ?? ''));
|
||||
if ($id !== '' && $label !== '') {
|
||||
$mapWhen[$id] = $label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('opd_dose_frequency')) {
|
||||
$f = $this->db->getFieldNames('opd_dose_frequency');
|
||||
$idF = $this->resolveFirstField($f, ['dose_freq_id', 'id']);
|
||||
$labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']);
|
||||
if ($idF !== null && $labelF !== null) {
|
||||
$rows = $this->db->table('opd_dose_frequency')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$id = trim((string) ($r['id'] ?? ''));
|
||||
$label = trim((string) ($r['label'] ?? ''));
|
||||
if ($id !== '' && $label !== '') {
|
||||
$mapFreq[$id] = $label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data['rx_medicines'] as &$medRow) {
|
||||
$doseRaw = trim((string) ($medRow['dosage'] ?? ''));
|
||||
$whenRaw = trim((string) ($medRow['dosage_when'] ?? ''));
|
||||
$freqRaw = trim((string) ($medRow['dosage_freq'] ?? ''));
|
||||
$medRow['dosage_label'] = ($doseRaw !== '' && isset($mapDose[$doseRaw])) ? $mapDose[$doseRaw] : $doseRaw;
|
||||
$medRow['dosage_when_label'] = ($whenRaw !== '' && isset($mapWhen[$whenRaw])) ? $mapWhen[$whenRaw] : $whenRaw;
|
||||
$medRow['dosage_freq_label'] = ($freqRaw !== '' && isset($mapFreq[$freqRaw])) ? $mapFreq[$freqRaw] : $freqRaw;
|
||||
|
||||
$genericInline = trim((string) ($medRow['genericname'] ?? ($medRow['salt_name'] ?? '')));
|
||||
if ($genericInline === '') {
|
||||
$medId = (int) ($medRow['med_id'] ?? 0);
|
||||
if ($medId > 0) {
|
||||
$missingGenericMedIds[$medId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($medRow);
|
||||
|
||||
if (!empty($missingGenericMedIds) && $this->db->tableExists('opd_med_master')) {
|
||||
$masterFields = $this->db->getFieldNames('opd_med_master');
|
||||
$masterIdField = $this->resolveFirstField($masterFields, ['id']);
|
||||
$masterGenericField = $this->resolveFirstField($masterFields, ['genericname', 'generic_name']);
|
||||
$masterSaltField = $this->resolveFirstField($masterFields, ['salt_name', 'sal_name', 'salt', 'saltname']);
|
||||
$labelField = $masterSaltField ?? $masterGenericField;
|
||||
|
||||
if ($masterIdField !== null && $labelField !== null) {
|
||||
$rows = $this->db->table('opd_med_master')
|
||||
->select($masterIdField . ' as med_id,' . $labelField . ' as generic_fallback')
|
||||
->whereIn($masterIdField, array_map('intval', array_keys($missingGenericMedIds)))
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$id = (int) ($r['med_id'] ?? 0);
|
||||
$label = trim((string) ($r['generic_fallback'] ?? ''));
|
||||
if ($id > 0 && $label !== '') {
|
||||
$medGenericMap[$id] = $label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data['rx_medicines'] as &$medRow) {
|
||||
$genericInline = trim((string) ($medRow['genericname'] ?? ($medRow['salt_name'] ?? '')));
|
||||
if ($genericInline === '') {
|
||||
$fallback = $medGenericMap[(int) ($medRow['med_id'] ?? 0)] ?? '';
|
||||
if ($fallback !== '') {
|
||||
$medRow['genericname'] = $fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($medRow);
|
||||
}
|
||||
} else {
|
||||
$data['rx_medicines'] = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,20 @@
|
|||
}
|
||||
.rx-save-status {
|
||||
font-size: .875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
min-width: 0;
|
||||
max-width: 320px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#rx_status_text {
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rx-tab-btn.active {
|
||||
background: #0d6efd;
|
||||
|
|
@ -123,6 +137,56 @@
|
|||
float: right;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.rx-label-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.rx-label-actions .rx-label-title {
|
||||
font-weight: 600;
|
||||
color: #344054;
|
||||
}
|
||||
.rx-complaint-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
}
|
||||
.rx-complaint-actions .rx-ai-btn {
|
||||
float: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
.btn-complaints-rewrite {
|
||||
border-color: #2b6cb0;
|
||||
color: #2b6cb0;
|
||||
background: #eef6ff;
|
||||
}
|
||||
.btn-complaints-rewrite:hover {
|
||||
background: #2b6cb0;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-complaints-mic {
|
||||
border-color: #13795b;
|
||||
color: #13795b;
|
||||
background: #ecfdf3;
|
||||
min-width: 120px;
|
||||
}
|
||||
.btn-medical-stt {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
background: #ecfeff;
|
||||
min-width: 120px;
|
||||
}
|
||||
.btn-medical-stt:hover {
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-complaints-mic:hover {
|
||||
background: #13795b;
|
||||
color: #fff;
|
||||
}
|
||||
.rx-scan-banner {
|
||||
display: none;
|
||||
border: 1px solid #b6d4fe;
|
||||
|
|
@ -222,8 +286,6 @@
|
|||
<li><button type="button" class="dropdown-item text-warning" id="btn_print2">Print Rx With OPD Head LetterHead</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_toggle_fhir_history" title="Show/Hide FHIR Export History">📄 FHIR History</button>
|
||||
<a href="javascript:load_form('<?= base_url('Opd/invoice') ?>/<?= esc($opd_id) ?>','OPD Invoice');" class="btn btn-secondary btn-sm">Back to Invoice</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rx_scan_banner" class="rx-scan-banner"></div>
|
||||
|
|
@ -499,18 +561,19 @@
|
|||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Complaints
|
||||
<button type="button" class="btn btn-outline-primary btn-sm rx-ai-btn btn-ai-rewrite" data-target="complaints" data-mode="hinglish_to_english">Hinglish -> English</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm rx-ai-btn btn-ai-rewrite btn-complaints-rewrite" data-target="complaints" data-mode="hinglish_to_english" title="Convert Hinglish text to English">↔ Hinglish → English</button>
|
||||
<button type="button" class="btn btn-outline-success btn-sm rx-ai-btn" id="btn_complaints_mic" title="Speech to text for complaints">Mic</button>
|
||||
</label>
|
||||
<textarea class="form-control rx-field" id="complaints" rows="4" maxlength="4000"><?= esc($opd_prescription[0]->complaints ?? '') ?></textarea>
|
||||
<div id="complaints_interim_preview" style="display:none;font-size:.8rem;color:#6c757d;padding:2px 4px;font-style:italic;"></div>
|
||||
<div class="rx-counter" id="counter_complaints">0/4000</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Examination
|
||||
</label>
|
||||
<div class="small text-muted mb-1">AI Assist is optional. Use it only if you want cleaner clinical wording; manual typing works as usual.</div>
|
||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="finding_examinations" data-mode="finding">AI Assist</button>
|
||||
<button type="button" class="btn btn-sm btn-medical-stt" data-target="finding_examinations" data-label="🎙 Med Mic">🎙 Med Mic</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="finding_examinations" data-target="finding_examinations">Past Data</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="finding_examinations" data-target="finding_examinations">Load Template</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="finding_examinations" data-target="finding_examinations">Save as Template</button>
|
||||
|
|
@ -524,7 +587,7 @@
|
|||
<label class="form-label">Diagnosis
|
||||
</label>
|
||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="diagnosis" data-mode="diagnosis">AI Assist</button>
|
||||
<button type="button" class="btn btn-sm btn-medical-stt" data-target="diagnosis" data-label="🎙 Med Mic">🎙 Med Mic</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="diagnosis" data-target="diagnosis">Past Data</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="diagnosis" data-target="diagnosis">Load Template</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="diagnosis" data-target="diagnosis">Save as Template</button>
|
||||
|
|
@ -538,7 +601,7 @@
|
|||
<label class="form-label">Provisional Diagnosis
|
||||
</label>
|
||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="provisional_diagnosis" data-mode="diagnosis">AI Assist</button>
|
||||
<button type="button" class="btn btn-sm btn-medical-stt" data-target="provisional_diagnosis" data-label="🎙 Med Mic">🎙 Med Mic</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Past Data</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Load Template</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Save as Template</button>
|
||||
|
|
@ -552,7 +615,6 @@
|
|||
<label class="form-label">Prescription
|
||||
</label>
|
||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="prescriber_remarks" data-mode="autotype">AI Assist</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="prescriber_remarks" data-target="prescriber_remarks">Past Data</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="prescriber_remarks" data-target="prescriber_remarks">Load Template</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="prescriber_remarks" data-target="prescriber_remarks">Save as Template</button>
|
||||
|
|
@ -572,7 +634,6 @@
|
|||
<div class="mb-3">
|
||||
<label class="form-label"><strong>Advice Add</strong></label>
|
||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="advice" data-mode="autotype">AI Assist</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="advice" data-target="advice">Past Data</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="advice" data-target="advice">Load Template</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="advice" data-target="advice">Save as Template</button>
|
||||
|
|
@ -717,6 +778,7 @@
|
|||
</div>
|
||||
<div class="col-md-2 d-grid gap-2">
|
||||
<button type="button" class="btn btn-primary" id="btn_add_medicine">Add</button>
|
||||
<button type="button" class="btn btn-outline-danger" id="btn_clear_medicine">Remove All</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="btn_cancel_medicine_edit" style="display:none;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -777,6 +839,8 @@
|
|||
<div class="d-flex flex-wrap gap-1 mb-2" id="rx_template_suggest_box">
|
||||
<span class="text-muted small">No suggestion available.</span>
|
||||
</div>
|
||||
<label class="form-label">Search Template</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" id="rx_template_search" placeholder="Search by name or content...">
|
||||
<label class="form-label">Select Template</label>
|
||||
<select class="form-select form-select-sm" id="rx_template_select"></select>
|
||||
<label class="form-label mt-2">Apply Mode</label>
|
||||
|
|
@ -883,6 +947,20 @@
|
|||
var latestLocalAssist = null;
|
||||
var complaintSuggestions = [];
|
||||
var selectedComplaints = [];
|
||||
var complaintsMicMode = 'off';
|
||||
var complaintsSpeechRecognition = null;
|
||||
var complaintsMicActive = false;
|
||||
var complaintsMediaRecorder = null;
|
||||
var complaintsMediaStream = null;
|
||||
var complaintsMediaChunks = [];
|
||||
var complaintsBaseText = '';
|
||||
var medicalSttTarget = '';
|
||||
var medicalSttRecorder = null;
|
||||
var medicalSttStream = null;
|
||||
var medicalSttChunks = [];
|
||||
var medicalSttActive = false;
|
||||
var complaintsSttServerHealthUrl = 'http://139.59.13.39:8000/health';
|
||||
var complaintsSttServerTranscribeUrl = 'http://139.59.13.39:8000/stt/transcribe';
|
||||
var draftKey = 'opd_rx_draft_' + ($('#opd_id').val() || '0');
|
||||
var templateLoadState = { target: '', section: '', rows: [] };
|
||||
var templateSaveState = { section: '', text: '', scope: 'doctor' };
|
||||
|
|
@ -894,6 +972,7 @@
|
|||
var invCustomProfileKey = 'opd_inv_custom_profiles';
|
||||
var legacyInvestigationProfiles = {};
|
||||
var legacyInvestigationProfileList = [];
|
||||
var investigationBatchActive = false;
|
||||
var activeMedicineScope = 'active';
|
||||
var medicineSuggestRows = [];
|
||||
var investigationProfiles = {
|
||||
|
|
@ -950,6 +1029,421 @@
|
|||
}
|
||||
}
|
||||
|
||||
function setComplaintsMicMode(mode, note) {
|
||||
complaintsMicMode = mode;
|
||||
var $btn = $('#btn_complaints_mic');
|
||||
if (!$btn.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.removeClass('btn-outline-success btn-outline-primary btn-outline-secondary btn-danger');
|
||||
|
||||
if (mode === 'server') {
|
||||
$btn.addClass('btn-outline-success').prop('disabled', false).text('🎙 Server Mic');
|
||||
} else if (mode === 'browser') {
|
||||
$btn.addClass('btn-outline-primary').prop('disabled', false).text('🎤 Browser Mic');
|
||||
} else {
|
||||
$btn.addClass('btn-outline-secondary').prop('disabled', true).text('⏸ Mic Off');
|
||||
}
|
||||
|
||||
if (note) {
|
||||
$('.jsError').removeClass('text-danger text-success').addClass('text-muted').text(note);
|
||||
}
|
||||
}
|
||||
|
||||
function setComplaintsMicListening(listening) {
|
||||
var $btn = $('#btn_complaints_mic');
|
||||
if (!$btn.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
complaintsMicActive = listening;
|
||||
if (listening) {
|
||||
$btn.removeClass('btn-outline-success btn-outline-primary btn-outline-secondary').addClass('btn-danger').text('⏹ Stop Mic');
|
||||
} else {
|
||||
setComplaintsMicMode(complaintsMicMode);
|
||||
}
|
||||
}
|
||||
|
||||
function appendTranscriptToComplaints(text) {
|
||||
var transcript = (text || '').toString().trim();
|
||||
if (!transcript) {
|
||||
return;
|
||||
}
|
||||
|
||||
var current = ($('#complaints').val() || '').toString().trim();
|
||||
var nextVal = current ? (current + '\n' + transcript) : transcript;
|
||||
$('#complaints').val(nextVal);
|
||||
refreshCounters();
|
||||
markDirty('Speech text added in complaints');
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
function appendTranscriptToField(targetId, text) {
|
||||
var transcript = (text || '').toString().trim();
|
||||
if (!transcript || !targetId || !$('#' + targetId).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var current = ($('#' + targetId).val() || '').toString().trim();
|
||||
var nextVal = current ? (current + '\n' + transcript) : transcript;
|
||||
$('#' + targetId).val(nextVal);
|
||||
refreshCounters();
|
||||
markDirty('Medical speech text added');
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
function getMedicalContextForField(targetId) {
|
||||
if (targetId === 'finding_examinations') {
|
||||
return 'examination findings';
|
||||
}
|
||||
if (targetId === 'diagnosis') {
|
||||
return 'diagnosis notes';
|
||||
}
|
||||
if (targetId === 'provisional_diagnosis') {
|
||||
return 'provisional diagnosis notes';
|
||||
}
|
||||
return 'clinical notes';
|
||||
}
|
||||
|
||||
function setMedicalMicButtonState(activeTarget) {
|
||||
$('.btn-medical-stt').each(function() {
|
||||
var $btn = $(this);
|
||||
var target = ($btn.data('target') || '').toString();
|
||||
var baseText = ($btn.data('label') || '🎙 Med Mic').toString();
|
||||
|
||||
$btn.removeClass('btn-danger').addClass('btn-medical-stt').prop('disabled', false).text(baseText);
|
||||
|
||||
if (activeTarget && target === activeTarget) {
|
||||
$btn.removeClass('btn-medical-stt').addClass('btn-danger').text('⏹ Stop Mic');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopMedicalSttStream() {
|
||||
if (medicalSttStream && medicalSttStream.getTracks) {
|
||||
medicalSttStream.getTracks().forEach(function(track) {
|
||||
track.stop();
|
||||
});
|
||||
}
|
||||
medicalSttStream = null;
|
||||
medicalSttRecorder = null;
|
||||
medicalSttChunks = [];
|
||||
}
|
||||
|
||||
function sendMedicalAudioToServer(audioBlob, targetId) {
|
||||
var formData = new FormData();
|
||||
formData.append('audio', audioBlob, (targetId || 'clinical') + '.webm');
|
||||
formData.append('lang', 'en-IN');
|
||||
formData.append('medical_context', getMedicalContextForField(targetId));
|
||||
|
||||
return fetch(complaintsSttServerTranscribeUrl, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
body: formData
|
||||
}).then(function(response) {
|
||||
if (!response.ok) {
|
||||
return response.json().catch(function() {
|
||||
return {};
|
||||
}).then(function(payload) {
|
||||
var err = new Error(payload.detail || ('Server response: ' + response.status));
|
||||
err.statusCode = response.status;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
}).then(function(payload) {
|
||||
var text = String((payload && (payload.text || payload.transcript || payload.result)) || '').trim();
|
||||
if (!text) {
|
||||
throw new Error('Empty transcript from server');
|
||||
}
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
function startMedicalServerStt(targetId) {
|
||||
if (!targetId || !$('#' + targetId).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech is not available in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (medicalSttActive && medicalSttRecorder) {
|
||||
if (medicalSttTarget === targetId) {
|
||||
medicalSttRecorder.stop();
|
||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Processing medical transcript...');
|
||||
return;
|
||||
}
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical mic is already recording another field. Stop it first.');
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
|
||||
medicalSttTarget = targetId;
|
||||
medicalSttStream = stream;
|
||||
medicalSttRecorder = new MediaRecorder(stream);
|
||||
medicalSttChunks = [];
|
||||
|
||||
medicalSttRecorder.ondataavailable = function(event) {
|
||||
if (event.data && event.data.size > 0) {
|
||||
medicalSttChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
medicalSttRecorder.onstop = function() {
|
||||
var blob = new Blob(medicalSttChunks, {
|
||||
type: (medicalSttRecorder && medicalSttRecorder.mimeType) ? medicalSttRecorder.mimeType : 'audio/webm'
|
||||
});
|
||||
var targetForTranscript = medicalSttTarget;
|
||||
|
||||
stopMedicalSttStream();
|
||||
medicalSttActive = false;
|
||||
medicalSttTarget = '';
|
||||
setMedicalMicButtonState('');
|
||||
|
||||
sendMedicalAudioToServer(blob, targetForTranscript).then(function(transcript) {
|
||||
appendTranscriptToField(targetForTranscript, transcript);
|
||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Medical speech text added.');
|
||||
}).catch(function(error) {
|
||||
if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech input error: ' + (error.message || 'Invalid request'));
|
||||
return;
|
||||
}
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech server unavailable. Please try again.');
|
||||
});
|
||||
};
|
||||
|
||||
medicalSttActive = true;
|
||||
setMedicalMicButtonState(targetId);
|
||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Recording medical speech... click same Mic to stop.');
|
||||
medicalSttRecorder.start();
|
||||
}).catch(function() {
|
||||
medicalSttActive = false;
|
||||
medicalSttTarget = '';
|
||||
setMedicalMicButtonState('');
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Microphone permission denied for medical speech.');
|
||||
});
|
||||
}
|
||||
|
||||
function checkComplaintsSttServer() {
|
||||
if (!window.fetch || !window.AbortController) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
var controller = new AbortController();
|
||||
var timeoutId = window.setTimeout(function() {
|
||||
controller.abort();
|
||||
}, 3500);
|
||||
|
||||
return fetch(complaintsSttServerHealthUrl, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
signal: controller.signal
|
||||
}).then(function(response) {
|
||||
return !!(response && response.ok);
|
||||
}).catch(function() {
|
||||
return false;
|
||||
}).finally(function() {
|
||||
window.clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
function sendComplaintsAudioToServer(audioBlob) {
|
||||
var formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'complaints.webm');
|
||||
formData.append('lang', 'en-IN');
|
||||
formData.append('medical_context', (selectedComplaints || []).join(', '));
|
||||
|
||||
return fetch(complaintsSttServerTranscribeUrl, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
body: formData
|
||||
}).then(function(response) {
|
||||
if (!response.ok) {
|
||||
return response.json().catch(function() {
|
||||
return {};
|
||||
}).then(function(payload) {
|
||||
var err = new Error(payload.detail || ('Server response: ' + response.status));
|
||||
err.statusCode = response.status;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
}).then(function(payload) {
|
||||
var text = String((payload && (payload.text || payload.transcript || payload.result)) || '').trim();
|
||||
if (!text) {
|
||||
throw new Error('Empty transcript from server');
|
||||
}
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
function stopComplaintsMediaStream() {
|
||||
if (complaintsMediaStream && complaintsMediaStream.getTracks) {
|
||||
complaintsMediaStream.getTracks().forEach(function(track) {
|
||||
track.stop();
|
||||
});
|
||||
}
|
||||
complaintsMediaStream = null;
|
||||
complaintsMediaRecorder = null;
|
||||
complaintsMediaChunks = [];
|
||||
}
|
||||
|
||||
function startComplaintsBrowserStt() {
|
||||
if (!complaintsSpeechRecognition) {
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Browser speech recognition is not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (complaintsMicActive) {
|
||||
complaintsSpeechRecognition.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
setComplaintsMicListening(true);
|
||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Listening complaints (browser mode)...');
|
||||
|
||||
try {
|
||||
complaintsSpeechRecognition.start();
|
||||
} catch (e) {
|
||||
setComplaintsMicListening(false);
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Could not start browser speech recognition.');
|
||||
}
|
||||
}
|
||||
|
||||
function startComplaintsServerStt() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
if (complaintsSpeechRecognition) {
|
||||
setComplaintsMicMode('browser', 'Server recording is unavailable. Switched to browser mode.');
|
||||
startComplaintsBrowserStt();
|
||||
return;
|
||||
}
|
||||
setComplaintsMicMode('off', 'Speech is not available in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (complaintsMicActive && complaintsMediaRecorder) {
|
||||
complaintsMediaRecorder.stop();
|
||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Processing server transcript...');
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
|
||||
complaintsMediaStream = stream;
|
||||
complaintsMediaRecorder = new MediaRecorder(stream);
|
||||
complaintsMediaChunks = [];
|
||||
|
||||
complaintsMediaRecorder.ondataavailable = function(event) {
|
||||
if (event.data && event.data.size > 0) {
|
||||
complaintsMediaChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
complaintsMediaRecorder.onstop = function() {
|
||||
var blob = new Blob(complaintsMediaChunks, {
|
||||
type: (complaintsMediaRecorder && complaintsMediaRecorder.mimeType) ? complaintsMediaRecorder.mimeType : 'audio/webm'
|
||||
});
|
||||
|
||||
stopComplaintsMediaStream();
|
||||
setComplaintsMicListening(false);
|
||||
|
||||
sendComplaintsAudioToServer(blob).then(function(transcript) {
|
||||
appendTranscriptToComplaints(transcript);
|
||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Complaints speech text added.');
|
||||
}).catch(function(error) {
|
||||
if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Speech input error: ' + (error.message || 'Invalid request'));
|
||||
return;
|
||||
}
|
||||
if (complaintsSpeechRecognition) {
|
||||
setComplaintsMicMode('browser', 'Server STT unavailable. Switched to browser mode.');
|
||||
} else {
|
||||
setComplaintsMicMode('off', 'Server STT unavailable and browser speech not supported.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setComplaintsMicListening(true);
|
||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Recording complaints... click Mic again to stop.');
|
||||
complaintsMediaRecorder.start();
|
||||
}).catch(function() {
|
||||
if (complaintsSpeechRecognition) {
|
||||
setComplaintsMicMode('browser', 'Microphone permission failed for server mode. Switched to browser mode.');
|
||||
} else {
|
||||
setComplaintsMicMode('off', 'Microphone permission denied.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initComplaintsSpeech() {
|
||||
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (SpeechRecognition) {
|
||||
complaintsSpeechRecognition = new SpeechRecognition();
|
||||
complaintsSpeechRecognition.lang = 'en-IN';
|
||||
complaintsSpeechRecognition.interimResults = true;
|
||||
complaintsSpeechRecognition.continuous = false;
|
||||
|
||||
complaintsSpeechRecognition.onresult = function(event) {
|
||||
var interimTranscript = '';
|
||||
var finalTranscript = '';
|
||||
for (var i = event.resultIndex; i < event.results.length; i++) {
|
||||
var t = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalTranscript += t;
|
||||
} else {
|
||||
interimTranscript += t;
|
||||
}
|
||||
}
|
||||
|
||||
if (interimTranscript) {
|
||||
// Show interim as a non-destructive preview below the textarea
|
||||
$('#complaints_interim_preview').text('🎤 ' + interimTranscript).show();
|
||||
}
|
||||
|
||||
if (finalTranscript.trim()) {
|
||||
// Final: clear preview, append once to the base text
|
||||
$('#complaints_interim_preview').hide().text('');
|
||||
appendTranscriptToComplaints(finalTranscript.trim());
|
||||
complaintsBaseText = ($('#complaints').val() || '').toString().trim();
|
||||
}
|
||||
};
|
||||
|
||||
complaintsSpeechRecognition.onerror = function(event) {
|
||||
setComplaintsMicListening(false);
|
||||
$('#complaints_interim_preview').hide().text('');
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Browser speech error: ' + (event.error || 'unknown'));
|
||||
};
|
||||
|
||||
complaintsSpeechRecognition.onend = function() {
|
||||
setComplaintsMicListening(false);
|
||||
$('#complaints_interim_preview').hide().text('');
|
||||
};
|
||||
}
|
||||
|
||||
if (complaintsSpeechRecognition) {
|
||||
setComplaintsMicMode('browser', 'Complaints Mic is ready (browser mode for local language).');
|
||||
} else {
|
||||
setComplaintsMicMode('off', 'Complaints Mic unavailable in this browser.');
|
||||
}
|
||||
|
||||
$('#btn_complaints_mic').on('click', function() {
|
||||
if (complaintsMicMode === 'browser') {
|
||||
startComplaintsBrowserStt();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initMedicalSpeechButtons() {
|
||||
setMedicalMicButtonState('');
|
||||
$(document).on('click', '.btn-medical-stt', function() {
|
||||
var targetId = ($(this).data('target') || '').toString();
|
||||
startMedicalServerStt(targetId);
|
||||
});
|
||||
}
|
||||
|
||||
function getCustomInvestigationProfiles() {
|
||||
try {
|
||||
var parsed = JSON.parse(localStorage.getItem(invCustomProfileKey) || '[]');
|
||||
|
|
@ -1104,6 +1598,7 @@
|
|||
function setStatus(type, message) {
|
||||
var $badge = $('#rx_status_badge');
|
||||
var $text = $('#rx_status_text');
|
||||
var statusMessage = (message || '').toString();
|
||||
$badge.removeClass('bg-secondary bg-warning bg-success bg-danger');
|
||||
|
||||
if (type === 'saved') {
|
||||
|
|
@ -1118,7 +1613,7 @@
|
|||
$badge.addClass('bg-secondary').text('Not Saved');
|
||||
}
|
||||
|
||||
$text.text(message || '');
|
||||
$text.text(statusMessage).attr('title', statusMessage);
|
||||
}
|
||||
|
||||
function updateScanBanner() {
|
||||
|
|
@ -1879,6 +2374,40 @@
|
|||
});
|
||||
}
|
||||
|
||||
function renderTemplateSelectOptions(searchQuery) {
|
||||
var list = Array.isArray(templateLoadState.rows) ? templateLoadState.rows : [];
|
||||
var query = (searchQuery || '').toString().trim().toLowerCase();
|
||||
var $sel = $('#rx_template_select');
|
||||
$sel.empty();
|
||||
|
||||
var added = 0;
|
||||
list.forEach(function(row, idx) {
|
||||
var name = (row.template_name || ('Template ' + (idx + 1))).toString();
|
||||
var src = parseInt(row.doc_id || '0', 10) === 0 ? '[Master] ' : '[My] ';
|
||||
var previewText = (row.template_text || '').toString();
|
||||
var haystack = (name + ' ' + previewText + ' ' + src).toLowerCase();
|
||||
if (query && haystack.indexOf(query) === -1) {
|
||||
return;
|
||||
}
|
||||
$sel.append('<option value="' + idx + '">' + $('<div>').text(src + name).html() + '</option>');
|
||||
added++;
|
||||
});
|
||||
|
||||
if (!added) {
|
||||
$sel.append('<option value="">No templates match search</option>');
|
||||
$('#rx_template_preview').val('');
|
||||
return;
|
||||
}
|
||||
|
||||
var firstVal = ($sel.find('option:first').val() || '').toString();
|
||||
var firstIdx = parseInt(firstVal, 10);
|
||||
if (!isNaN(firstIdx) && firstIdx >= 0) {
|
||||
$sel.val(String(firstIdx));
|
||||
var firstRow = list[firstIdx] || {};
|
||||
$('#rx_template_preview').val(firstRow.template_text || '');
|
||||
}
|
||||
}
|
||||
|
||||
$(document).on('click', '.btn-template-load', function() {
|
||||
var target = ($(this).data('target') || '').toString();
|
||||
var section = ($(this).data('section') || '').toString();
|
||||
|
|
@ -1896,22 +2425,14 @@
|
|||
|
||||
var rankedRows = rankTemplateRows(rows, target);
|
||||
templateLoadState = { target: target, section: section, rows: rankedRows };
|
||||
var $sel = $('#rx_template_select');
|
||||
$sel.empty();
|
||||
rankedRows.forEach(function(row, idx) {
|
||||
var name = row.template_name || ('Template ' + (idx + 1));
|
||||
var src = parseInt(row.doc_id || '0', 10) === 0 ? '[Master] ' : '[My] ';
|
||||
$sel.append('<option value="' + idx + '">' + $('<div>').text(src + name).html() + '</option>');
|
||||
});
|
||||
$('#rx_template_search').val('');
|
||||
renderTemplateSelectOptions('');
|
||||
|
||||
renderTemplateQuickSuggestions(rankedRows);
|
||||
|
||||
var existingText = ($('#' + target).val() || '').toString().trim();
|
||||
$('#rx_template_apply_mode').val(existingText ? 'append' : 'replace');
|
||||
|
||||
var firstText = (rankedRows[0] && rankedRows[0].template_text) ? rankedRows[0].template_text : '';
|
||||
$('#rx_template_preview').val(firstText);
|
||||
|
||||
if (window.bootstrap && window.bootstrap.Modal) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(document.getElementById('rxTemplateModal')).show();
|
||||
} else {
|
||||
|
|
@ -1921,11 +2442,19 @@
|
|||
});
|
||||
|
||||
$('#rx_template_select').on('change', function() {
|
||||
var idx = parseInt($(this).val() || '0', 10);
|
||||
var idx = parseInt($(this).val() || '-1', 10);
|
||||
if (isNaN(idx) || idx < 0) {
|
||||
$('#rx_template_preview').val('');
|
||||
return;
|
||||
}
|
||||
var row = (templateLoadState.rows || [])[idx] || {};
|
||||
$('#rx_template_preview').val(row.template_text || '');
|
||||
});
|
||||
|
||||
$('#rx_template_search').on('input', function() {
|
||||
renderTemplateSelectOptions($(this).val() || '');
|
||||
});
|
||||
|
||||
$('#btn_apply_template_choice').on('click', function() {
|
||||
var idx = parseInt($('#rx_template_select').val() || '0', 10);
|
||||
var row = (templateLoadState.rows || [])[idx] || null;
|
||||
|
|
@ -1976,6 +2505,10 @@
|
|||
if (idx < 0) {
|
||||
return;
|
||||
}
|
||||
if (!$('#rx_template_select option[value="' + idx + '"]').length) {
|
||||
$('#rx_template_search').val('');
|
||||
renderTemplateSelectOptions('');
|
||||
}
|
||||
$('#rx_template_select').val(String(idx)).trigger('change');
|
||||
});
|
||||
|
||||
|
|
@ -2255,6 +2788,8 @@
|
|||
refreshCounters();
|
||||
setStatus('normal', 'No local changes');
|
||||
renderComplaintChips();
|
||||
initComplaintsSpeech();
|
||||
initMedicalSpeechButtons();
|
||||
setTimeout(loadPatientScanHistory, 50);
|
||||
|
||||
$(document).on('click', '.btn-ai-rewrite', function() {
|
||||
|
|
@ -2620,6 +3155,10 @@
|
|||
return map;
|
||||
}
|
||||
|
||||
function normalizeInvestigationKey(name) {
|
||||
return (name || '').toString().trim().toLowerCase();
|
||||
}
|
||||
|
||||
function addInvestigationEntry(name, code, done) {
|
||||
name = (name || '').trim();
|
||||
code = (code || '').trim();
|
||||
|
|
@ -2669,10 +3208,23 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (investigationBatchActive) {
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Please wait, previous profile is still being added.');
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = getExistingInvestigationNames();
|
||||
var localSeen = {};
|
||||
var queue = rows.filter(function(row) {
|
||||
var key = ((row && row.name) ? row.name : '').toString().trim().toLowerCase();
|
||||
return key && !existing[key];
|
||||
var key = normalizeInvestigationKey((row && row.name) ? row.name : '');
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
if (existing[key] || localSeen[key]) {
|
||||
return false;
|
||||
}
|
||||
localSeen[key] = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!queue.length) {
|
||||
|
|
@ -2680,19 +3232,26 @@
|
|||
return;
|
||||
}
|
||||
|
||||
investigationBatchActive = true;
|
||||
|
||||
var idx = 0;
|
||||
var added = 0;
|
||||
function next() {
|
||||
if (idx >= queue.length) {
|
||||
investigationBatchActive = false;
|
||||
loadInvestigationList();
|
||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text(added + ' test(s) added.');
|
||||
markDirty('Investigation shortcuts applied');
|
||||
return;
|
||||
}
|
||||
var row = queue[idx++] || {};
|
||||
var key = normalizeInvestigationKey(row.name || '');
|
||||
addInvestigationEntry((row.name || '').toString(), (row.code || '').toString(), function(ok) {
|
||||
if (ok) {
|
||||
added++;
|
||||
if (key) {
|
||||
existing[key] = true;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
|
@ -2757,6 +3316,13 @@
|
|||
return;
|
||||
}
|
||||
|
||||
var existing = getExistingInvestigationNames();
|
||||
var key = normalizeInvestigationKey(name);
|
||||
if (key && existing[key]) {
|
||||
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('This investigation is already added.');
|
||||
return;
|
||||
}
|
||||
|
||||
addInvestigationEntry(name, code, function(ok) {
|
||||
if (ok) {
|
||||
$('#investigation_name').val('');
|
||||
|
|
@ -2916,12 +3482,15 @@
|
|||
}
|
||||
rows.forEach(function(row) {
|
||||
var rowId = parseInt(row.id || 0, 10);
|
||||
var dosageText = row.dosage_label || row.dosage || '';
|
||||
var whenText = row.dosage_when_label || row.dosage_when || '';
|
||||
var freqText = row.dosage_freq_label || row.dosage_freq || '';
|
||||
$tb.append('<tr>' +
|
||||
'<td>' + esc(row.med_name) + '</td>' +
|
||||
'<td>' + esc(row.med_type) + '</td>' +
|
||||
'<td>' + esc(row.dosage) + '</td>' +
|
||||
'<td>' + esc(row.dosage_when) + '</td>' +
|
||||
'<td>' + esc(row.dosage_freq) + '</td>' +
|
||||
'<td>' + esc(dosageText) + '</td>' +
|
||||
'<td>' + esc(whenText) + '</td>' +
|
||||
'<td>' + esc(freqText) + '</td>' +
|
||||
'<td>' + esc(row.no_of_days) + '</td>' +
|
||||
'<td>' + esc(row.qty) + '</td>' +
|
||||
'<td>' + esc(row.remark) + '</td>' +
|
||||
|
|
@ -3453,6 +4022,37 @@
|
|||
});
|
||||
});
|
||||
|
||||
$('#btn_clear_medicine').on('click', function() {
|
||||
var ids = [];
|
||||
$('#tbl_medicine tbody .btn-del-med').each(function() {
|
||||
var id = parseInt($(this).data('id') || '0', 10);
|
||||
if (id > 0) {
|
||||
ids.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (!ids.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var idx = 0;
|
||||
function removeNext() {
|
||||
if (idx >= ids.length) {
|
||||
clearMedicineForm(true);
|
||||
loadMedicineList();
|
||||
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('All medicines removed.');
|
||||
markDirty('Medicine list cleared');
|
||||
return;
|
||||
}
|
||||
|
||||
var id = ids[idx++];
|
||||
apiPost('<?= base_url('Opd_prescription/medicine_remove') ?>/' + id, {}, function() {
|
||||
removeNext();
|
||||
});
|
||||
}
|
||||
removeNext();
|
||||
});
|
||||
|
||||
$(document).on('click', '.med-chip', function() {
|
||||
var target = $(this).data('target');
|
||||
var value = $(this).data('value') || '';
|
||||
|
|
@ -3640,7 +4240,7 @@
|
|||
});
|
||||
|
||||
if (localStorage.getItem(draftKey)) {
|
||||
setStatus('dirty', 'Draft available (click Restore Draft)');
|
||||
setStatus('dirty', 'Draft available');
|
||||
}
|
||||
|
||||
loadAdviceList();
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ footer: html_myFooter;
|
|||
|
||||
<p style="margin-top: 20px; font-size: 10px;">
|
||||
<strong>Investigation:</strong> {{investigation}}<br>
|
||||
<strong>Advice:</strong> {{advice}}
|
||||
<strong>Advice:</strong> {{advice}}{{advice_local_line}}
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 40px; text-align: right;">
|
||||
|
|
|
|||
599
app/Views/tools/speech_test.php
Normal file
599
app/Views/tools/speech_test.php
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Speech To Text Test</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg1: #f0f9ff;
|
||||
--bg2: #ecfeff;
|
||||
--card: #ffffff;
|
||||
--text: #1f2937;
|
||||
--muted: #64748b;
|
||||
--line: #dbeafe;
|
||||
--focus: #0369a1;
|
||||
--focus-soft: #e0f2fe;
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fee2e2;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||
color: var(--text);
|
||||
background: linear-gradient(130deg, var(--bg1), var(--bg2));
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: var(--focus);
|
||||
box-shadow: 0 0 0 3px var(--focus-soft);
|
||||
}
|
||||
|
||||
.mic-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mic-btn:hover {
|
||||
border-color: var(--focus);
|
||||
}
|
||||
|
||||
.mic-btn.mode-server {
|
||||
border-color: #059669;
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.mic-btn.mode-browser {
|
||||
border-color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.mic-btn.mode-off {
|
||||
border-color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mic-btn.listening {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.35);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(220, 38, 38, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 20px;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint-wrap {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint-wrap label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.hint-wrap input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hint-wrap input:focus {
|
||||
border-color: var(--focus);
|
||||
box-shadow: 0 0 0 3px var(--focus-soft);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.legend-dot.server {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.legend-dot.browser {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.legend-dot.off {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.field input {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.mic-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<h1>Speech To Text Test</h1>
|
||||
<p class="note">Click the mic beside any textbox and speak. That textbox will be updated.</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<label for="speech-lang">Language:</label>
|
||||
<select id="speech-lang">
|
||||
<option value="en-IN">English (India)</option>
|
||||
<option value="hi-IN">Hindi (India)</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="legend-dot server"></span>Server API mode</span>
|
||||
<span class="legend-item"><span class="legend-dot browser"></span>Browser fallback mode</span>
|
||||
<span class="legend-item"><span class="legend-dot off"></span>Speech unavailable</span>
|
||||
</div>
|
||||
|
||||
<div class="hint-wrap">
|
||||
<label for="medical-context">Optional medical terms hint (comma separated):</label>
|
||||
<input id="medical-context" type="text" placeholder="Example: Paracetamol, Cefixime, Amoxicillin clavulanate, Hypertension, Diabetes Mellitus">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<input id="speech-input-1" type="text" placeholder="Textbox 1">
|
||||
<button type="button" class="mic-btn" data-target="speech-input-1" aria-label="Speak for textbox 1">🎤</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<input id="speech-input-2" type="text" placeholder="Textbox 2">
|
||||
<button type="button" class="mic-btn" data-target="speech-input-2" aria-label="Speak for textbox 2">🎤</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<input id="speech-input-3" type="text" placeholder="Textbox 3">
|
||||
<button type="button" class="mic-btn" data-target="speech-input-3" aria-label="Speak for textbox 3">🎤</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<input id="speech-input-4" type="text" placeholder="Textbox 4">
|
||||
<button type="button" class="mic-btn" data-target="speech-input-4" aria-label="Speak for textbox 4">🎤</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<input id="speech-input-5" type="text" placeholder="Textbox 5">
|
||||
<button type="button" class="mic-btn" data-target="speech-input-5" aria-label="Speak for textbox 5">🎤</button>
|
||||
</div>
|
||||
|
||||
<div id="speech-status" class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const statusEl = document.getElementById('speech-status');
|
||||
const micButtons = document.querySelectorAll('.mic-btn');
|
||||
const langEl = document.getElementById('speech-lang');
|
||||
const medicalContextEl = document.getElementById('medical-context');
|
||||
|
||||
const SERVER_TRANSCRIBE_URL = 'http://139.59.13.39:8000/stt/transcribe';
|
||||
const SERVER_HEALTH_URL = 'http://139.59.13.39:8000/health';
|
||||
const SERVER_TIMEOUT_MS = 4000;
|
||||
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const hasBrowserSTT = !!SpeechRecognition;
|
||||
const hasMediaRecorder = typeof window.MediaRecorder !== 'undefined';
|
||||
|
||||
let mode = 'off';
|
||||
let recognition = null;
|
||||
let activeInput = null;
|
||||
let activeButton = null;
|
||||
|
||||
let mediaStream = null;
|
||||
let mediaRecorder = null;
|
||||
let mediaChunks = [];
|
||||
let isServerRecording = false;
|
||||
|
||||
function setStatus(text, isError) {
|
||||
statusEl.textContent = text;
|
||||
if (isError) {
|
||||
statusEl.classList.add('error');
|
||||
} else {
|
||||
statusEl.classList.remove('error');
|
||||
}
|
||||
}
|
||||
|
||||
function setListeningButton(button) {
|
||||
micButtons.forEach((item) => item.classList.remove('listening'));
|
||||
if (button) {
|
||||
button.classList.add('listening');
|
||||
}
|
||||
}
|
||||
|
||||
function applyMode(newMode, message, isError) {
|
||||
mode = newMode;
|
||||
|
||||
micButtons.forEach((button) => {
|
||||
button.classList.remove('mode-server', 'mode-browser', 'mode-off');
|
||||
if (mode === 'server') {
|
||||
button.classList.add('mode-server');
|
||||
button.disabled = false;
|
||||
} else if (mode === 'browser') {
|
||||
button.classList.add('mode-browser');
|
||||
button.disabled = false;
|
||||
} else {
|
||||
button.classList.add('mode-off');
|
||||
button.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
setStatus(message, !!isError);
|
||||
}
|
||||
|
||||
function setupBrowserRecognition() {
|
||||
if (!hasBrowserSTT) {
|
||||
return;
|
||||
}
|
||||
|
||||
recognition = new SpeechRecognition();
|
||||
recognition.lang = langEl ? langEl.value : 'en-IN';
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = false;
|
||||
|
||||
recognition.onresult = function (event) {
|
||||
let transcript = '';
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
}
|
||||
|
||||
if (activeInput) {
|
||||
activeInput.value = transcript.trim();
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = function (event) {
|
||||
setListeningButton(null);
|
||||
activeButton = null;
|
||||
setStatus('Speech error: ' + event.error, true);
|
||||
};
|
||||
|
||||
recognition.onend = function () {
|
||||
setListeningButton(null);
|
||||
activeButton = null;
|
||||
setStatus('Done.', false);
|
||||
};
|
||||
}
|
||||
|
||||
async function checkServerAvailability() {
|
||||
if (!window.fetch || !window.AbortController) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(function () {
|
||||
controller.abort();
|
||||
}, SERVER_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(SERVER_HEALTH_URL, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAudioToServer(audioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'speech.webm');
|
||||
formData.append('lang', langEl ? langEl.value : 'en-IN');
|
||||
formData.append('medical_context', medicalContextEl ? medicalContextEl.value : '');
|
||||
|
||||
const response = await fetch(SERVER_TRANSCRIBE_URL, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const errorPayload = await response.json();
|
||||
detail = String(errorPayload.detail || '').trim();
|
||||
} catch (ignoreError) {
|
||||
detail = '';
|
||||
}
|
||||
|
||||
const err = new Error(detail || ('Server response: ' + response.status));
|
||||
err.statusCode = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const text = String(payload.text || payload.transcript || payload.result || '').trim();
|
||||
if (!text) {
|
||||
throw new Error('Empty transcript from server');
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function stopServerStream() {
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(function (track) {
|
||||
track.stop();
|
||||
});
|
||||
}
|
||||
|
||||
mediaStream = null;
|
||||
mediaRecorder = null;
|
||||
mediaChunks = [];
|
||||
isServerRecording = false;
|
||||
}
|
||||
|
||||
async function startServerRecording(button, input) {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
applyMode(hasBrowserSTT ? 'browser' : 'off', hasBrowserSTT
|
||||
? 'Microphone recording not available for server mode. Switched to browser fallback.'
|
||||
: 'Microphone recording is not available in this browser.', !hasBrowserSTT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isServerRecording && activeButton === button && mediaRecorder) {
|
||||
mediaRecorder.stop();
|
||||
setStatus('Processing audio on server...', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isServerRecording && mediaRecorder) {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
|
||||
activeInput = input;
|
||||
activeButton = button;
|
||||
setListeningButton(button);
|
||||
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorder = new MediaRecorder(mediaStream);
|
||||
mediaChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = function (event) {
|
||||
if (event.data && event.data.size > 0) {
|
||||
mediaChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async function () {
|
||||
const audioBlob = new Blob(mediaChunks, { type: mediaRecorder && mediaRecorder.mimeType ? mediaRecorder.mimeType : 'audio/webm' });
|
||||
stopServerStream();
|
||||
setListeningButton(null);
|
||||
|
||||
if (!activeInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const transcript = await sendAudioToServer(audioBlob);
|
||||
activeInput.value = transcript;
|
||||
setStatus('Transcription complete using server API.', false);
|
||||
} catch (error) {
|
||||
if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
setStatus('Server validation error: ' + (error.message || 'Invalid request'), true);
|
||||
} else if (hasBrowserSTT) {
|
||||
applyMode('browser', 'Server API unavailable. Switched to browser fallback mode.', false);
|
||||
} else {
|
||||
applyMode('off', 'Server API unavailable and browser fallback is not supported.', true);
|
||||
}
|
||||
} finally {
|
||||
activeButton = null;
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isServerRecording = true;
|
||||
setStatus('Recording... click same mic again to stop and transcribe.', false);
|
||||
} catch (error) {
|
||||
stopServerStream();
|
||||
setListeningButton(null);
|
||||
if (hasBrowserSTT) {
|
||||
applyMode('browser', 'Could not start server recording. Switched to browser fallback mode.', false);
|
||||
} else {
|
||||
applyMode('off', 'Could not access microphone and fallback is unavailable.', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startBrowserRecognition(button, input) {
|
||||
if (!recognition) {
|
||||
applyMode('off', 'Browser speech recognition is not supported in this browser.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeButton === button) {
|
||||
recognition.stop();
|
||||
setStatus('Stopped listening.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
activeInput = input;
|
||||
activeButton = button;
|
||||
setListeningButton(button);
|
||||
setStatus('Listening with browser fallback... please speak now.', false);
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (error) {
|
||||
setStatus('Could not start browser speech recognition. Please try again.', true);
|
||||
}
|
||||
}
|
||||
|
||||
if (langEl) {
|
||||
langEl.addEventListener('change', function () {
|
||||
if (recognition) {
|
||||
recognition.lang = langEl.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
micButtons.forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
const targetId = button.getAttribute('data-target');
|
||||
const input = targetId ? document.getElementById(targetId) : null;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'server') {
|
||||
startServerRecording(button, input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'browser') {
|
||||
startBrowserRecognition(button, input);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(async function init() {
|
||||
setupBrowserRecognition();
|
||||
|
||||
const serverAvailable = hasMediaRecorder && await checkServerAvailability();
|
||||
if (serverAvailable) {
|
||||
applyMode('server', 'Server API mode active. Mic color indicates server transcription.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasBrowserSTT) {
|
||||
applyMode('browser', 'Server API unavailable. Browser fallback mode is active (medical-term accuracy may be lower).', false);
|
||||
return;
|
||||
}
|
||||
|
||||
applyMode('off', 'Speech is unavailable: server API is down and browser fallback is not supported.', true);
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user